Files
signal-platform/frontend/src/api/admin.ts
T
dennisthiessenandClaude Opus 5 22bee28ac7 feat(jobs): persist each job's last run so it survives a restart
Job outcomes lived only in scheduler._job_runtime, an in-memory dict. Every
deploy wiped it, so Admin -> Jobs could report "Active" with no indication a job
had ever run or how it ended -- which is the main thing that page is for.

New job_run_state table (migration 031): one row per job, upserted on job_name.
Deliberately not history -- system_events already grows unbounded with no
retention job, and a second append-only operational table would repeat that
debt. Adding history later is purely additive.

Written from two hooks, NOT from _runtime_finish. That looked cheapest (one
function, ~40 call sites) but unit tests invoke job coroutines directly, so it
would fire detached DB writes at the real session factory throughout the suite,
and there is no testing flag to guard on.

  - An APScheduler EVENT_JOB_EXECUTED/ERROR listener covers everything the
    scheduler fires, including manual triggers. Its detached task is held in a
    module-level set (a bare create_task result can be collected mid-flight) and
    drained in the app lifespan before engine.dispose().
  - _run_pipeline persists directly, and must: pipeline steps are plain
    coroutine calls that emit no scheduler events, so the listener cannot see
    them. The step persist sits AFTER the except that swallows step errors --
    inside it, exactly the failed runs worth seeing would be skipped. The
    orchestrator persists in the finally, and the disabled early-return persists
    too, or "skipped" is silently dropped.

_persist_job_run never raises: a persistence failure must not break an otherwise
successful pipeline.

The API reports this as last_run_* and leaves runtime_* meaning strictly live
in-memory state. Reusing runtime_status would have been a regression, not a
no-op: JobControls drives the status chip from it (a job that errored eight days
ago would read "Last run error" forever instead of "Active") and picks the
rate-limit banner from it (a week-old rate limit would pin the banner
permanently). Tests pin the split.

The table starts empty; each job fills its row the next time it finishes. No
backfill from system_events, which records only warning/error outcomes under a
different status vocabulary and would invent successes that never happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:19:29 +02:00

351 lines
9.3 KiB
TypeScript

import apiClient from './client';
import type {
ActivationConfig,
AdminUser,
AlertConfig,
AlertTestResult,
PipelineReadiness,
RecommendationConfig,
ScheduleConfig,
SentimentProviderConfig,
SentimentTestResult,
SystemSetting,
TickerUniverse,
TickerUniverseBootstrapResult,
TickerUniverseSetting,
} from '../lib/types';
// Users
export function listUsers() {
return apiClient.get<AdminUser[]>('admin/users').then((r) => r.data);
}
export function createUser(data: {
username: string;
password: string;
role: string;
has_access: boolean;
}) {
return apiClient.post<AdminUser>('admin/users', data).then((r) => r.data);
}
export function updateAccess(userId: number, hasAccess: boolean) {
return apiClient
.put<{ message: string }>(`admin/users/${userId}/access`, {
has_access: hasAccess,
})
.then((r) => r.data);
}
export function resetPassword(userId: number, password: string) {
return apiClient
.put<{ message: string }>(`admin/users/${userId}/password`, { new_password: password })
.then((r) => r.data);
}
// Settings
export function listSettings() {
return apiClient
.get<SystemSetting[]>('admin/settings')
.then((r) => r.data);
}
export function updateSetting(key: string, value: string) {
return apiClient
.put<{ message: string }>(`admin/settings/${key}`, { value })
.then((r) => r.data);
}
export function getRecommendationSettings() {
return apiClient
.get<RecommendationConfig>('admin/settings/recommendations')
.then((r) => r.data);
}
export function updateRecommendationSettings(payload: Partial<RecommendationConfig>) {
return apiClient
.put<RecommendationConfig>('admin/settings/recommendations', payload)
.then((r) => r.data);
}
export function getActivationSettings() {
return apiClient
.get<ActivationConfig>('admin/settings/activation')
.then((r) => r.data);
}
export function updateActivationSettings(payload: Partial<ActivationConfig>) {
return apiClient
.put<ActivationConfig>('admin/settings/activation', payload)
.then((r) => r.data);
}
export function getScheduleSettings() {
return apiClient
.get<ScheduleConfig>('admin/settings/schedule')
.then((r) => r.data);
}
export function updateScheduleSettings(payload: Partial<ScheduleConfig>) {
return apiClient
.put<ScheduleConfig>('admin/settings/schedule', payload)
.then((r) => r.data);
}
export interface PerformanceConfig {
start_date: string;
}
export function getPerformanceSettings() {
return apiClient
.get<PerformanceConfig>('admin/settings/performance')
.then((r) => r.data);
}
export function updatePerformanceSettings(payload: Partial<PerformanceConfig>) {
return apiClient
.put<PerformanceConfig>('admin/settings/performance', payload)
.then((r) => r.data);
}
export interface ShadowBookConfig {
enabled: boolean;
capacity: number;
risk_pct: number;
start_equity: number;
}
export function getShadowBookSettings() {
return apiClient
.get<ShadowBookConfig>('admin/settings/shadow-book')
.then((r) => r.data);
}
export function updateShadowBookSettings(payload: Partial<ShadowBookConfig>) {
return apiClient
.put<ShadowBookConfig>('admin/settings/shadow-book', payload)
.then((r) => r.data);
}
export function getSentimentSettings() {
return apiClient
.get<SentimentProviderConfig>('admin/settings/sentiment')
.then((r) => r.data);
}
export function updateSentimentSettings(payload: {
provider?: string;
model?: string;
api_key?: string;
}) {
return apiClient
.put<SentimentProviderConfig>('admin/settings/sentiment', payload)
.then((r) => r.data);
}
export function testSentimentSettings(ticker: string) {
return apiClient
.post<SentimentTestResult>('admin/settings/sentiment/test', { ticker })
.then((r) => r.data);
}
export function getAlertSettings() {
return apiClient
.get<AlertConfig>('admin/settings/alerts')
.then((r) => r.data);
}
export function updateAlertSettings(payload: {
enabled?: boolean;
bot_token?: string;
telegram_chat_id?: string;
qualified_enabled?: boolean;
sr_proximity_enabled?: boolean;
score_drop_enabled?: boolean;
digest_enabled?: boolean;
regime_quadrant_enabled?: boolean;
trade_closed_enabled?: boolean;
}) {
return apiClient
.put<AlertConfig>('admin/settings/alerts', payload)
.then((r) => r.data);
}
export function testAlertSettings() {
return apiClient
.post<AlertTestResult>('admin/settings/alerts/test')
.then((r) => r.data);
}
export function getTickerUniverseSetting() {
return apiClient
.get<TickerUniverseSetting>('admin/settings/ticker-universe')
.then((r) => r.data);
}
export function updateTickerUniverseSetting(universe: TickerUniverse) {
return apiClient
.put<TickerUniverseSetting>('admin/settings/ticker-universe', { universe })
.then((r) => r.data);
}
export function bootstrapTickers(universe: TickerUniverse, pruneMissing: boolean) {
return apiClient
.post<TickerUniverseBootstrapResult>('admin/tickers/bootstrap', null, {
params: {
universe,
prune_missing: pruneMissing,
},
})
.then((r) => r.data);
}
export function backfillTickerNames() {
return apiClient
.post<{ updated: number; checked: number; unmatched: number }>('admin/tickers/backfill-names')
.then((r) => r.data);
}
// Jobs
export type JobCategory = 'pipeline' | 'pipeline_step' | 'scheduled' | 'manual';
export type NextRunSource = 'own_schedule' | 'via_pipeline' | 'manual_only';
export interface JobStatus {
name: string;
label: string;
enabled: boolean;
registered: boolean;
category?: JobCategory;
/** Server-assigned ordering; the payload already arrives grouped by it. */
sort_order?: [number, number];
/** Parent pipelines for a step. Many-to-many: data_collector runs in all four. */
pipelines?: string[];
/** Step names, for a pipeline row. */
steps?: string[];
next_run_at: string | null;
next_run_source?: NextRunSource;
/** For a step: the soonest enabled parent's next run, and which parent. */
via_next_run_at?: string | null;
via_next_run_job?: string | null;
running?: boolean;
/** runtime_* is live, in-memory state only — it resets when the app restarts. */
runtime_status?: string | null;
runtime_processed?: number | null;
runtime_total?: number | null;
runtime_progress_pct?: number | null;
runtime_current_ticker?: string | null;
runtime_started_at?: string | null;
runtime_finished_at?: string | null;
runtime_message?: string | null;
/** last_run_* is persisted and survives restarts. Kept separate from
* runtime_* so a stale error cannot pin the status chip or the banner. */
last_run_at?: string | null;
last_run_status?: string | null;
last_run_message?: string | null;
last_run_processed?: number | null;
last_run_total?: number | null;
}
export interface TriggerJobResponse {
job: string;
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
message: string;
target_model?: BacktestTargetModel;
cadence?: BacktestCadence;
}
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
export type BacktestCadence = 'weekly' | 'daily';
export function listJobs() {
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
}
export function getPipelineReadiness() {
return apiClient.get<PipelineReadiness[]>('admin/pipeline/readiness').then((r) => r.data);
}
export function toggleJob(jobName: string, enabled: boolean) {
return apiClient
.put<{ message: string }>(`admin/jobs/${jobName}/toggle`, { enabled })
.then((r) => r.data);
}
export function triggerJob(
jobName: string,
options?: { target_model?: BacktestTargetModel; cadence?: BacktestCadence },
) {
return apiClient
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
.then((r) => r.data);
}
// System events (operational warnings / errors)
export interface SystemEvent {
id: number;
severity: 'warning' | 'error' | string;
source: string;
code: string;
message: string;
symbol: string | null;
created_at: string | null;
acknowledged_at: string | null;
}
export interface SystemEventSummary {
days: number;
total: number;
unacknowledged: number;
unacknowledged_errors: number;
unacknowledged_warnings: number;
}
export function listSystemEvents(params?: {
days?: number;
severity?: string;
unacknowledged_only?: boolean;
}) {
return apiClient
.get<SystemEvent[]>('admin/system-events', { params })
.then((r) => r.data);
}
export function getSystemEventSummary(days = 7) {
return apiClient
.get<SystemEventSummary>('admin/system-events/summary', { params: { days } })
.then((r) => r.data);
}
export function acknowledgeSystemEvents(days = 7) {
return apiClient
.post<{ acknowledged: number }>('admin/system-events/acknowledge', null, {
params: { days },
})
.then((r) => r.data);
}
// Data cleanup
export interface CleanupResult {
ohlcv: number;
sentiment: number;
fundamentals: number;
sr_refresh_ok: number;
sr_refresh_failed: number;
sr_refresh_failures: { symbol: string; error: string }[];
}
export function cleanupData(olderThanDays: number) {
return apiClient
.post<CleanupResult>('admin/data/cleanup', {
older_than_days: olderThanDays,
})
.then((r) => r.data);
}
// Track record
export function resetTrackRecord() {
return apiClient
.post<{ trade_setups: number }>('admin/track-record/reset')
.then((r) => r.data);
}