feat(downloads): add task event timeline

This commit is contained in:
saltbo
2026-06-29 21:07:00 -04:00
parent 74c0d0cd75
commit 3695c80c2e
22 changed files with 4948 additions and 82 deletions
+231 -9
View File
@@ -159,6 +159,48 @@ func (e DownloadTaskStatusState) Valid() bool {
}
}
// Defines values for DownloadTaskTimelineItemSeverity.
const (
DownloadTaskTimelineItemSeverityError DownloadTaskTimelineItemSeverity = "error"
DownloadTaskTimelineItemSeverityInfo DownloadTaskTimelineItemSeverity = "info"
DownloadTaskTimelineItemSeveritySuccess DownloadTaskTimelineItemSeverity = "success"
DownloadTaskTimelineItemSeverityWarning DownloadTaskTimelineItemSeverity = "warning"
)
// Valid indicates whether the value is a known member of the DownloadTaskTimelineItemSeverity enum.
func (e DownloadTaskTimelineItemSeverity) Valid() bool {
switch e {
case DownloadTaskTimelineItemSeverityError:
return true
case DownloadTaskTimelineItemSeverityInfo:
return true
case DownloadTaskTimelineItemSeveritySuccess:
return true
case DownloadTaskTimelineItemSeverityWarning:
return true
default:
return false
}
}
// Defines values for DownloadTaskTimelineItemSource.
const (
Activity DownloadTaskTimelineItemSource = "activity"
Task DownloadTaskTimelineItemSource = "task"
)
// Valid indicates whether the value is a known member of the DownloadTaskTimelineItemSource enum.
func (e DownloadTaskTimelineItemSource) Valid() bool {
switch e {
case Activity:
return true
case Task:
return true
default:
return false
}
}
// Defines values for DownloaderEngine.
const (
DownloaderEngineAria2 DownloaderEngine = "aria2"
@@ -1136,13 +1178,13 @@ func (e UpdateStorageJSONBodyStatus) Valid() bool {
// Defines values for CancelOrderJSONBodyStatus.
const (
CancelOrderJSONBodyStatusCanceled CancelOrderJSONBodyStatus = "canceled"
Canceled CancelOrderJSONBodyStatus = "canceled"
)
// Valid indicates whether the value is a known member of the CancelOrderJSONBodyStatus enum.
func (e CancelOrderJSONBodyStatus) Valid() bool {
switch e {
case CancelOrderJSONBodyStatusCanceled:
case Canceled:
return true
default:
return false
@@ -1450,12 +1492,15 @@ type DownloadTask struct {
ChargedCredits int64 `json:"chargedCredits"`
State DownloadTaskStatusBillingState `json:"state"`
} `json:"billing"`
Error *struct {
DownloadCompletedAt *string `json:"downloadCompletedAt"`
Error *struct {
Code *string `json:"code,omitempty"`
Message *string `json:"message"`
} `json:"error"`
FinishedAt *string `json:"finishedAt"`
Output *struct {
FinishedAt *string `json:"finishedAt"`
IngestCompletedAt *string `json:"ingestCompletedAt"`
IngestStartedAt *string `json:"ingestStartedAt"`
Output *struct {
ObjectId string `json:"objectId"`
} `json:"output"`
Progress struct {
@@ -1470,7 +1515,9 @@ type DownloadTask struct {
TotalBytes *int64 `json:"totalBytes,omitempty"`
} `json:"upload"`
} `json:"progress"`
Runtime *struct {
ResolveCompletedAt *string `json:"resolveCompletedAt"`
ResolveStartedAt *string `json:"resolveStartedAt"`
Runtime *struct {
Connections *int `json:"connections,omitempty"`
Engine *DownloadTaskStatusRuntimeEngine `json:"engine,omitempty"`
EtaSeconds *int `json:"etaSeconds,omitempty"`
@@ -1530,9 +1577,11 @@ type DownloadTask struct {
} `json:"trackers,omitempty"`
UpdatedAt *string `json:"updatedAt,omitempty"`
} `json:"runtime"`
StartedAt *string `json:"startedAt"`
State DownloadTaskStatusState `json:"state"`
UpdatedAt string `json:"updatedAt"`
SeedingStartedAt *string `json:"seedingStartedAt"`
SeedingStoppedAt *string `json:"seedingStoppedAt"`
StartedAt *string `json:"startedAt"`
State DownloadTaskStatusState `json:"state"`
UpdatedAt string `json:"updatedAt"`
} `json:"status"`
}
@@ -1559,6 +1608,30 @@ type DownloadTaskPage struct {
Total int `json:"total"`
}
// DownloadTaskTimeline defines model for DownloadTaskTimeline.
type DownloadTaskTimeline struct {
Items []DownloadTaskTimelineItem `json:"items"`
}
// DownloadTaskTimelineItem defines model for DownloadTaskTimelineItem.
type DownloadTaskTimelineItem struct {
Action string `json:"action"`
Detail *string `json:"detail"`
Id string `json:"id"`
Metadata *map[string]*interface{} `json:"metadata"`
Severity DownloadTaskTimelineItemSeverity `json:"severity"`
Source DownloadTaskTimelineItemSource `json:"source"`
TaskId string `json:"taskId"`
Time string `json:"time"`
Title string `json:"title"`
}
// DownloadTaskTimelineItemSeverity defines model for DownloadTaskTimelineItem.Severity.
type DownloadTaskTimelineItemSeverity string
// DownloadTaskTimelineItemSource defines model for DownloadTaskTimelineItem.Source.
type DownloadTaskTimelineItemSource string
// Downloader defines model for Downloader.
type Downloader struct {
Arch string `json:"arch"`
@@ -4650,6 +4723,9 @@ type ClientInterface interface {
RetryDownloadTask(ctx context.Context, id string, body RetryDownloadTaskJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
// ListDownloadTaskEvents request
ListDownloadTaskEvents(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error)
// SetDownloadTaskStatusWithBody request with any body
SetDownloadTaskStatusWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -7063,6 +7139,18 @@ func (c *Client) RetryDownloadTask(ctx context.Context, id string, body RetryDow
return c.Client.Do(req)
}
func (c *Client) ListDownloadTaskEvents(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewListDownloadTaskEventsRequest(c.Server, id)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
return nil, err
}
return c.Client.Do(req)
}
func (c *Client) SetDownloadTaskStatusWithBody(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewSetDownloadTaskStatusRequestWithBody(c.Server, id, contentType, body)
if err != nil {
@@ -13109,6 +13197,40 @@ func NewRetryDownloadTaskRequestWithBody(server string, id string, contentType s
return req, nil
}
// NewListDownloadTaskEventsRequest generates requests for ListDownloadTaskEvents
func NewListDownloadTaskEventsRequest(server string, id string) (*http.Request, error) {
var err error
var pathParam0 string
pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""})
if err != nil {
return nil, err
}
serverURL, err := url.Parse(server)
if err != nil {
return nil, err
}
operationPath := fmt.Sprintf("/api/downloads/tasks/%s/events", pathParam0)
if operationPath[0] == '/' {
operationPath = "." + operationPath
}
queryURL, err := serverURL.Parse(operationPath)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
if err != nil {
return nil, err
}
return req, nil
}
// NewSetDownloadTaskStatusRequest calls the generic SetDownloadTaskStatus builder with application/json body
func NewSetDownloadTaskStatusRequest(server string, id string, body SetDownloadTaskStatusJSONRequestBody) (*http.Request, error) {
var bodyReader io.Reader
@@ -18348,6 +18470,9 @@ type ClientWithResponsesInterface interface {
RetryDownloadTaskWithResponse(ctx context.Context, id string, body RetryDownloadTaskJSONRequestBody, reqEditors ...RequestEditorFn) (*RetryDownloadTaskResponse, error)
// ListDownloadTaskEventsWithResponse request
ListDownloadTaskEventsWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*ListDownloadTaskEventsResponse, error)
// SetDownloadTaskStatusWithBodyWithResponse request with any body
SetDownloadTaskStatusWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDownloadTaskStatusResponse, error)
@@ -23849,6 +23974,40 @@ func (r RetryDownloadTaskResponse) ContentType() string {
return ""
}
type ListDownloadTaskEventsResponse struct {
Body []byte
HTTPResponse *http.Response
JSON200 *DownloadTaskTimeline
JSON401 *Error
JSON403 *Error
JSON404 *Error
JSON409 *Error
}
// Status returns HTTPResponse.Status
func (r ListDownloadTaskEventsResponse) Status() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Status
}
return http.StatusText(0)
}
// StatusCode returns HTTPResponse.StatusCode
func (r ListDownloadTaskEventsResponse) StatusCode() int {
if r.HTTPResponse != nil {
return r.HTTPResponse.StatusCode
}
return 0
}
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
func (r ListDownloadTaskEventsResponse) ContentType() string {
if r.HTTPResponse != nil {
return r.HTTPResponse.Header.Get("Content-Type")
}
return ""
}
type SetDownloadTaskStatusResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -28893,6 +29052,15 @@ func (c *ClientWithResponses) RetryDownloadTaskWithResponse(ctx context.Context,
return ParseRetryDownloadTaskResponse(rsp)
}
// ListDownloadTaskEventsWithResponse request returning *ListDownloadTaskEventsResponse
func (c *ClientWithResponses) ListDownloadTaskEventsWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*ListDownloadTaskEventsResponse, error) {
rsp, err := c.ListDownloadTaskEvents(ctx, id, reqEditors...)
if err != nil {
return nil, err
}
return ParseListDownloadTaskEventsResponse(rsp)
}
// SetDownloadTaskStatusWithBodyWithResponse request with arbitrary body returning *SetDownloadTaskStatusResponse
func (c *ClientWithResponses) SetDownloadTaskStatusWithBodyWithResponse(ctx context.Context, id string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetDownloadTaskStatusResponse, error) {
rsp, err := c.SetDownloadTaskStatusWithBody(ctx, id, contentType, body, reqEditors...)
@@ -38025,6 +38193,60 @@ func ParseRetryDownloadTaskResponse(rsp *http.Response) (*RetryDownloadTaskRespo
return response, nil
}
// ParseListDownloadTaskEventsResponse parses an HTTP response from a ListDownloadTaskEventsWithResponse call
func ParseListDownloadTaskEventsResponse(rsp *http.Response) (*ListDownloadTaskEventsResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
defer func() { _ = rsp.Body.Close() }()
if err != nil {
return nil, err
}
response := &ListDownloadTaskEventsResponse{
Body: bodyBytes,
HTTPResponse: rsp,
}
switch {
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
var dest DownloadTaskTimeline
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON200 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON401 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON403 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON404 = &dest
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409:
var dest Error
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
return nil, err
}
response.JSON409 = &dest
}
return response, nil
}
// ParseSetDownloadTaskStatusResponse parses an HTTP response from a SetDownloadTaskStatusWithResponse call
func ParseSetDownloadTaskStatusResponse(rsp *http.Response) (*SetDownloadTaskStatusResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
+7
View File
@@ -0,0 +1,7 @@
ALTER TABLE `download_tasks` ADD `resolve_started_at` integer;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `resolve_completed_at` integer;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `download_completed_at` integer;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `ingest_started_at` integer;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `ingest_completed_at` integer;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `seeding_started_at` integer;--> statement-breakpoint
ALTER TABLE `download_tasks` ADD `seeding_stopped_at` integer;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -365,6 +365,13 @@
"when": 1782761700959,
"tag": "0052_rename-downloader-http-engine",
"breakpoints": true
},
{
"idx": 53,
"version": "6",
"when": 1782781065387,
"tag": "0053_striped_pride",
"breakpoints": true
}
]
}
+29
View File
@@ -122,5 +122,34 @@ export function createActivityRepo(db: Database): ActivityRepo {
return { items, total, page, pageSize }
},
async listByTarget(opts) {
const page = opts.page ?? 1
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 50))
const offset = (page - 1) * pageSize
const whereClause = and(
eq(activityEvents.orgId, opts.orgId),
eq(activityEvents.targetType, opts.targetType),
eq(activityEvents.targetId, opts.targetId),
)
const [countRows, rows] = await Promise.all([
db.select({ count: count() }).from(activityEvents).where(whereClause),
db
.select()
.from(activityEvents)
.where(whereClause)
.orderBy(desc(activityEvents.createdAt))
.limit(pageSize)
.offset(offset),
])
return {
items: rows,
total: countRows[0]?.count ?? 0,
page,
pageSize,
}
},
}
}
+14
View File
@@ -57,6 +57,13 @@ function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
errorMessage: row.errorMessage,
resultObjectId: row.resultObjectId,
runtime: row.runtime,
resolveStartedAt: row.resolveStartedAt,
resolveCompletedAt: row.resolveCompletedAt,
downloadCompletedAt: row.downloadCompletedAt,
ingestStartedAt: row.ingestStartedAt,
ingestCompletedAt: row.ingestCompletedAt,
seedingStartedAt: row.seedingStartedAt,
seedingStoppedAt: row.seedingStoppedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
assignedAt: row.assignedAt,
@@ -101,6 +108,13 @@ function toDownloadTask(row: DownloadTaskRow): DownloadTask {
output: row.resultObjectId ? { objectId: row.resultObjectId } : null,
runtime,
error: row.errorMessage ? { code: row.errorCode, message: row.errorMessage } : null,
resolveStartedAt: row.resolveStartedAt?.toISOString() ?? null,
resolveCompletedAt: row.resolveCompletedAt?.toISOString() ?? null,
downloadCompletedAt: row.downloadCompletedAt?.toISOString() ?? null,
ingestStartedAt: row.ingestStartedAt?.toISOString() ?? null,
ingestCompletedAt: row.ingestCompletedAt?.toISOString() ?? null,
seedingStartedAt: row.seedingStartedAt?.toISOString() ?? null,
seedingStoppedAt: row.seedingStoppedAt?.toISOString() ?? null,
startedAt: row.startedAt?.toISOString() ?? null,
finishedAt: row.finishedAt?.toISOString() ?? null,
updatedAt: row.updatedAt.toISOString(),
+7
View File
@@ -349,6 +349,13 @@ export const downloadTasks = sqliteTable(
errorMessage: text('error_message'),
resultObjectId: text('result_object_id'),
runtime: text('runtime'),
resolveStartedAt: integer('resolve_started_at', { mode: 'timestamp_ms' }),
resolveCompletedAt: integer('resolve_completed_at', { mode: 'timestamp_ms' }),
downloadCompletedAt: integer('download_completed_at', { mode: 'timestamp_ms' }),
ingestStartedAt: integer('ingest_started_at', { mode: 'timestamp_ms' }),
ingestCompletedAt: integer('ingest_completed_at', { mode: 'timestamp_ms' }),
seedingStartedAt: integer('seeding_started_at', { mode: 'timestamp_ms' }),
seedingStoppedAt: integer('seeding_stopped_at', { mode: 'timestamp_ms' }),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
assignedAt: integer('assigned_at', { mode: 'timestamp_ms' }),
@@ -1,4 +1,4 @@
import type { Downloader, DownloadTask } from '@shared/types'
import type { Downloader, DownloadTask, DownloadTaskTimelineItem } from '@shared/types'
import { sql } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { S3Service } from '../../adapters/gateways/s3.js'
@@ -1196,6 +1196,95 @@ describe('Download tasks API integration', () => {
expect(seedingTask.status.runtime).not.toHaveProperty('etaSeconds')
})
it('returns a task timeline from lifecycle fields and activity events [spec: download-tasks/events]', async () => {
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
await insertStorage(db)
const createdDownloader = await registerDownloaderThroughDeviceLogin(app, 'timeline-downloader')
const downloaderHeaders = {
Authorization: `Bearer ${createdDownloader.token}`,
'Content-Type': 'application/json',
}
await recordDownloaderHeartbeat(app, createdDownloader.token, { ...heartbeat, currentTasks: 0 })
const user = await authedHeaders(app, 'timeline-user@example.com')
const createTaskRes = await app.request('/api/downloads/tasks', {
method: 'POST',
headers: { ...user, 'Content-Type': 'application/json' },
body: JSON.stringify({
source: { type: 'magnet', uri: 'magnet:?xt=urn:btih:timeline' },
targetFolder: 'Remote Downloads',
name: 'timeline.torrent',
}),
})
expect(createTaskRes.status).toBe(201)
const task = (await createTaskRes.json()) as DownloadTask
await claimTaskForDownloader(app, createdDownloader.token, task.id)
const resolvingRes = await app.request(`/api/downloads/tasks/${task.id}`, {
method: 'PATCH',
headers: downloaderHeaders,
body: JSON.stringify({
status: 'downloading',
runtime: {
engine: 'aria2',
phase: 'metadata',
torrent: { infoHash: 'timeline-info-hash', peers: 0, seeders: 0 },
trackers: [{ url: 'udp://tracker.example/announce', status: 'announce' }],
},
}),
})
expect(resolvingRes.status).toBe(200)
const resolvingTask = (await resolvingRes.json()) as DownloadTask
expect(resolvingTask.status.resolveStartedAt).toBeTruthy()
const totalBytes = 1024 * 1024
const ingestingRes = await app.request(`/api/downloads/tasks/${task.id}`, {
method: 'PATCH',
headers: downloaderHeaders,
body: JSON.stringify({
status: 'uploading',
...transferProgress({ downloadBytes: totalBytes, uploadBytes: 512, totalBytes }),
runtime: {
engine: 'aria2',
phase: 'uploading',
progress: {
download: { bytes: totalBytes, totalBytes, bytesPerSecond: 0 },
upload: { bytes: 512, totalBytes, bytesPerSecond: 128 },
},
torrent: { infoHash: 'timeline-info-hash', peers: 2, seeders: 1 },
},
}),
})
expect(ingestingRes.status).toBe(200)
const ingestingTask = (await ingestingRes.json()) as DownloadTask
expect(ingestingTask.status.resolveCompletedAt).toBeTruthy()
expect(ingestingTask.status.downloadCompletedAt).toBeTruthy()
expect(ingestingTask.status.ingestStartedAt).toBeTruthy()
const eventsRes = await app.request(`/api/downloads/tasks/${task.id}/events`, { headers: user })
expect(eventsRes.status).toBe(200)
const body = (await eventsRes.json()) as { items: DownloadTaskTimelineItem[] }
const actions = body.items.map((item) => item.action)
expect(actions).toEqual(
expect.arrayContaining([
'download_task_created',
'download_task_assigned',
'download_resolve_started',
'download_resolve_completed',
'download_completed',
'download_ingest_started',
'download_task_ingesting',
]),
)
expect(body.items[0]?.time).toBeTruthy()
expect(body.items.find((item) => item.action === 'download_resolve_started')?.metadata).toMatchObject({
engine: 'aria2',
phase: 'metadata',
trackerCount: 1,
})
})
it('returns storage failure details when multipart upload session creation fails [spec: download-tasks/upload-session-failure]', async () => {
vi.mocked(S3Service.prototype.createMultipartUpload).mockRejectedValueOnce(
new Error('bucket does not support multipart'),
+21
View File
@@ -5,6 +5,7 @@ import {
downloadTaskPageSchema,
downloadTaskSchema,
downloadTaskStatusUpdateSchema,
downloadTaskTimelineSchema,
listDownloadTasksQuerySchema,
updateDownloadTaskSchema,
} from '@shared/schemas'
@@ -13,6 +14,7 @@ import type { Env } from '../../middleware/platform'
import {
createDownloadTask,
getDownloadTask,
getDownloadTaskTimeline,
listDownloadTasks,
performDownloadTaskAction,
updateDownloadTask,
@@ -101,6 +103,20 @@ const getRoute = createRoute({
},
})
const eventsRoute = createRoute({
operationId: 'listDownloadTaskEvents',
summary: 'List download task timeline events',
tags: ['Download Tasks'],
method: 'get',
path: '/{id}/events',
middleware: [requirePermission('remoteDownload', 'read')] as const,
request: { params: z.object({ id: z.string() }) },
responses: {
200: jsonContent(downloadTaskTimelineSchema, 'Download task timeline'),
...taskErrorResponses,
},
})
const updateRoute = createRoute({
operationId: 'updateDownloadTask',
summary: 'Update download task',
@@ -206,6 +222,11 @@ const downloadTasksRoute = new OpenAPIHono<Env>()
if (!orgId) throw unauthorized()
return c.json(await getDownloadTask(c.get('deps'), orgId, c.req.valid('param').id), 200)
})
.openapi(eventsRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw unauthorized()
return c.json(await getDownloadTaskTimeline(c.get('deps'), orgId, c.req.valid('param').id), 200)
})
.openapi(statusRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw unauthorized()
+7
View File
@@ -441,6 +441,13 @@ const APP_SCHEMA_SQL = `
error_message TEXT,
result_object_id TEXT,
runtime TEXT,
resolve_started_at INTEGER,
resolve_completed_at INTEGER,
download_completed_at INTEGER,
ingest_started_at INTEGER,
ingest_completed_at INTEGER,
seeding_started_at INTEGER,
seeding_stopped_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
assigned_at INTEGER,
@@ -69,6 +69,7 @@ function makeDeps(downloaders: Partial<DownloaderRepo> = {}) {
licenseBinding: {},
licensingCloud: {},
remoteDownloadUsage: {},
activity: {},
} as DownloadsDeps,
update,
}
+373 -3
View File
@@ -9,12 +9,14 @@ import type {
UpdateDownloadTaskInput,
} from '@shared/schemas'
import { downloadTaskRuntimeSchema } from '@shared/schemas'
import type { Downloader, DownloadTask, DownloadTaskRuntime } from '@shared/types'
import type { Downloader, DownloadTask, DownloadTaskRuntime, DownloadTaskTimelineItem } from '@shared/types'
import { nanoid } from 'nanoid'
import { ZPAN_CLOUD_URL_DEFAULT } from '../../../shared/constants'
import { hasFeature } from '../../domain/licensing'
import type { Platform } from '../../platform/interface'
import type {
ActivityEvent,
ActivityRepo,
DownloaderRepo,
DownloadTaskRecord,
DownloadTaskRepo,
@@ -23,6 +25,7 @@ import type {
LicensingCloudGateway,
ListDownloadTasksFilters,
RemoteDownloadUsageRepo,
UpdateDownloadTaskFields,
} from '../ports'
import { DownloadError, featureBlocked } from '../ports'
import { loadBindingState } from '../site/licensing'
@@ -41,6 +44,7 @@ export type DownloadsDeps = {
licenseBinding: LicenseBindingRepo
licensingCloud: LicensingCloudGateway
remoteDownloadUsage: RemoteDownloadUsageRepo
activity: ActivityRepo
}
const DEFAULT_REMOTE_DOWNLOAD_UNIT_BYTES = 100 * 1024 * 1024
@@ -50,6 +54,8 @@ const QUEUE_ASSIGN_BATCH = 20
const CONTROL_TASK_PAGE_SIZE = 100
const DOWNLOADER_ACTIVE_NEXT_POLL_SECONDS = 5
const DOWNLOADER_IDLE_NEXT_POLL_SECONDS = 60
const DOWNLOAD_TASK_TARGET_TYPE = 'download_task'
const TASK_EVENT_PAGE_SIZE = 100
const PAUSABLE_TASK_STATUSES = ['queued', 'assigned', 'downloading'] as const
const CANCELABLE_TASK_STATUSES = [
@@ -88,6 +94,18 @@ const DELETE_DOWNLOADER_REQUEUE_STATUSES = [
'canceling',
]
const STALE_REQUEUE_STATUSES = ['assigned', 'downloading', 'uploading', 'interrupted']
const LIFECYCLE_FIELDS = [
'resolveStartedAt',
'resolveCompletedAt',
'downloadCompletedAt',
'ingestStartedAt',
'ingestCompletedAt',
'seedingStartedAt',
'seedingStoppedAt',
] as const
type LifecycleField = (typeof LIFECYCLE_FIELDS)[number]
type TaskLifecycleFields = Partial<Pick<UpdateDownloadTaskFields, LifecycleField>>
// ─── Downloader registration / admin CRUD ───────────────────────────────────
@@ -251,6 +269,17 @@ export async function createDownloadTask(
assignedAt: null,
now,
})
await recordTaskActivity(deps, {
task: {
id,
orgId,
createdByUserId: userId,
displayName: input.name ?? null,
sourceUri: input.source.uri,
},
action: 'download_task_created',
metadata: { sourceType: input.source.type, targetFolder: input.targetFolder },
})
return deps.downloadTasks.get(orgId, id)
}
@@ -271,6 +300,25 @@ export function getDownloadTask(deps: DownloadsDeps, orgId: string, id: string):
return deps.downloadTasks.get(orgId, id)
}
export async function getDownloadTaskTimeline(
deps: DownloadsDeps,
orgId: string,
id: string,
): Promise<{ items: DownloadTaskTimelineItem[] }> {
const task = await deps.downloadTasks.getRecord(orgId, id)
const activity = await deps.activity.listByTarget({
orgId,
targetType: DOWNLOAD_TASK_TARGET_TYPE,
targetId: id,
page: 1,
pageSize: TASK_EVENT_PAGE_SIZE,
})
const activityItems = activity.items.map((event) => activityTimelineItem(task.id, event))
const activityActions = new Set(activityItems.map((item) => item.action))
const taskItems = taskLifecycleTimelineItems(task).filter((item) => !activityActions.has(item.action))
return { items: [...activityItems, ...taskItems].sort((a, b) => Date.parse(b.time) - Date.parse(a.time)) }
}
export async function updateDownloadTask(
deps: DownloadsDeps,
platform: Platform,
@@ -386,7 +434,8 @@ export async function updateDownloadTask(
const nextFinishedAt =
task.finishedAt ?? (input.status !== undefined && ['completed', 'failed', 'canceled'].includes(status) ? now : null)
await deps.downloadTasks.setFields(id, {
const lifecycleFields = taskLifecycleFields(task, currentRuntime, nextRuntime, status, now)
const fields = {
status,
billingAuthorizedBytes,
billingChargedBytes,
@@ -398,6 +447,17 @@ export async function updateDownloadTask(
startedAt: task.startedAt ?? (status === 'downloading' ? now : null),
finishedAt: nextFinishedAt,
updatedAt: now,
...lifecycleFields,
}
await deps.downloadTasks.setFields(id, fields)
await recordUpdateActivity(deps, task, {
input,
status,
previousStatus: task.status,
runtime: nextRuntime,
lifecycleFields,
billingStatus,
})
return deps.downloadTasks.get(task.orgId, id)
@@ -442,8 +502,10 @@ export async function performDownloadTaskAction(
}),
updatedAt: now,
})
await recordTaskActivity(deps, { task, action: 'download_task_deleted' })
return { id, deleted: true }
}
await recordTaskActivity(deps, { task, action: 'download_task_deleted' })
await deps.downloadTasks.delete(id)
return { id, deleted: true }
}
@@ -459,6 +521,10 @@ export async function performDownloadTaskAction(
runtime: serializeTaskRuntime(stoppedRuntime(task.runtime)),
updatedAt: now,
})
await recordTaskActivity(deps, {
task,
action: status === 'pausing' ? 'download_task_pause_requested' : 'download_task_paused',
})
return deps.downloadTasks.get(orgId, id)
}
@@ -473,6 +539,7 @@ export async function performDownloadTaskAction(
runtime: clearTaskRuntimeMessageJson(task.runtime),
updatedAt: now,
})
await recordTaskActivity(deps, { task, action: 'download_task_resume_requested' })
return deps.downloadTasks.get(orgId, id)
}
@@ -492,6 +559,10 @@ export async function performDownloadTaskAction(
finishedAt: status === 'canceled' ? (task.finishedAt ?? now) : task.finishedAt,
updatedAt: now,
})
await recordTaskActivity(deps, {
task,
action: status === 'canceling' ? 'download_task_cancel_requested' : 'download_task_canceled',
})
return deps.downloadTasks.get(orgId, id)
}
@@ -511,6 +582,7 @@ export async function performDownloadTaskAction(
finishedAt: null,
updatedAt: now,
})
await recordTaskActivity(deps, { task, action: 'download_task_retry_requested' })
return deps.downloadTasks.get(orgId, id)
}
@@ -535,6 +607,7 @@ export async function performDownloadTaskAction(
finishedAt: null,
updatedAt: now,
})
await recordTaskActivity(deps, { task, action: 'download_task_restart_requested' })
return deps.downloadTasks.get(orgId, id)
}
@@ -563,7 +636,14 @@ async function claimQueuedTasksForDownloader(
for (const task of tasks) {
if (remaining <= 0) break
if (!canDownloaderRunSource(params.capabilities, task.sourceType)) continue
if (await deps.downloadTasks.claimQueued(task.id, params.id, params.now)) remaining -= 1
if (await deps.downloadTasks.claimQueued(task.id, params.id, params.now)) {
remaining -= 1
await recordTaskActivity(deps, {
task,
action: 'download_task_assigned',
metadata: { downloaderId: params.id },
})
}
}
}
@@ -584,14 +664,47 @@ async function recoverStaleDownloaderAssignments(deps: DownloadsDeps): Promise<v
await deps.downloadTasks.clearStaleSeedingRuntime(leaseCutoff, now)
const unreachableIds = await deps.downloaders.listUnreachableIds(leaseCutoff)
if (unreachableIds.length > 0) {
const controls = await listTasksForDownloaders(deps, unreachableIds, ['canceling', 'pausing'])
await deps.downloadTasks.resolveControlAssignedToMany(unreachableIds, now)
await Promise.all(
controls.map((task) =>
recordTaskActivity(deps, {
task,
action: 'download_stale_control_resolved',
metadata: { previousStatus: task.status, downloaderId: task.assignedDownloaderId },
}),
),
)
}
// Requeue in-flight work and flip status to offline only on the online→offline
// transition, so already-handled tasks are not re-queued repeatedly.
const staleIds = await deps.downloaders.listStaleIds(leaseCutoff)
if (staleIds.length === 0) return
const requeued = await listTasksForDownloaders(deps, staleIds, STALE_REQUEUE_STATUSES)
await deps.downloadTasks.requeueAssignedToMany(staleIds, STALE_REQUEUE_STATUSES, now)
await deps.downloaders.markStaleOffline(staleIds, now)
await Promise.all(
requeued.map((task) =>
recordTaskActivity(deps, {
task,
action: 'download_stale_requeued',
metadata: { previousStatus: task.status, downloaderId: task.assignedDownloaderId },
}),
),
)
}
async function listTasksForDownloaders(
deps: DownloadsDeps,
downloaderIds: string[],
statuses: readonly string[],
): Promise<DownloadTaskRecord[]> {
const pages = await Promise.all(
downloaderIds.map((downloaderId) =>
deps.downloadTasks.list({ downloaderId, statuses: [...statuses], page: 1, pageSize: CONTROL_TASK_PAGE_SIZE }),
),
)
return pages.flatMap((page) => page.rows)
}
// ─── Upload-token minting ────────────────────────────────────────────────────
@@ -728,6 +841,256 @@ function clearTaskRuntimeMessage(runtime: DownloadTaskRuntime | null): DownloadT
return Object.keys(rest).length > 0 ? rest : null
}
function taskLifecycleFields(
task: DownloadTaskRecord,
current: DownloadTaskRuntime | null,
next: DownloadTaskRuntime | null,
status: string,
now: Date,
): TaskLifecycleFields {
const fields: TaskLifecycleFields = {}
const currentPhase = current?.phase
const nextPhase = next?.phase
if (!task.resolveStartedAt && nextPhase === 'metadata') fields.resolveStartedAt = now
if (!task.resolveCompletedAt && currentPhase === 'metadata' && nextPhase && nextPhase !== 'metadata') {
fields.resolveCompletedAt = now
}
if (!task.downloadCompletedAt && (status === 'uploading' || status === 'completed' || nextPhase === 'uploading')) {
fields.downloadCompletedAt = now
}
if (!task.ingestStartedAt && (status === 'uploading' || nextPhase === 'uploading')) fields.ingestStartedAt = now
if (!task.ingestCompletedAt && status === 'completed') fields.ingestCompletedAt = now
if (!task.seedingStartedAt && nextPhase === 'seeding') fields.seedingStartedAt = now
if (!task.seedingStoppedAt && currentPhase === 'seeding' && nextPhase && nextPhase !== 'seeding') {
fields.seedingStoppedAt = now
}
return fields
}
async function recordUpdateActivity(
deps: DownloadsDeps,
task: DownloadTaskRecord,
params: {
input: UpdateDownloadTaskInput
status: string
previousStatus: string
runtime: DownloadTaskRuntime | null
lifecycleFields: TaskLifecycleFields
billingStatus: string
},
): Promise<void> {
for (const field of LIFECYCLE_FIELDS) {
if (!params.lifecycleFields[field]) continue
await recordTaskActivity(deps, {
task,
action: lifecycleAction(field),
metadata: runtimeMetadata(params.runtime),
})
}
if (params.previousStatus !== params.status) {
await recordTaskActivity(deps, {
task,
action: statusAction(params.status),
metadata: {
from: params.previousStatus,
to: params.status,
...runtimeMetadata(params.runtime),
},
})
}
if (params.input.errorMessage) {
await recordTaskActivity(deps, {
task,
action: 'download_task_error',
metadata: { message: params.input.errorMessage },
})
}
if (params.billingStatus === 'insufficient_credits' && task.billingStatus !== 'insufficient_credits') {
await recordTaskActivity(deps, {
task,
action: 'download_task_billing_suspended',
metadata: { reason: 'insufficient_credits' },
})
}
}
function lifecycleAction(field: LifecycleField): string {
if (field === 'resolveStartedAt') return 'download_resolve_started'
if (field === 'resolveCompletedAt') return 'download_resolve_completed'
if (field === 'downloadCompletedAt') return 'download_completed'
if (field === 'ingestStartedAt') return 'download_ingest_started'
if (field === 'ingestCompletedAt') return 'download_ingest_completed'
if (field === 'seedingStartedAt') return 'download_seeding_started'
return 'download_seeding_stopped'
}
function statusAction(status: string): string {
if (status === 'downloading') return 'download_task_started'
if (status === 'uploading') return 'download_task_ingesting'
if (status === 'completed') return 'download_task_completed'
if (status === 'failed') return 'download_task_failed'
if (status === 'canceled') return 'download_task_canceled'
if (status === 'suspended') return 'download_task_suspended'
if (status === 'paused') return 'download_task_paused'
if (status === 'pausing') return 'download_task_pause_requested'
if (status === 'canceling') return 'download_task_cancel_requested'
if (status === 'queued') return 'download_task_queued'
if (status === 'assigned') return 'download_task_assigned'
return `download_task_${status}`
}
async function recordTaskActivity(
deps: DownloadsDeps,
input: {
task: Pick<DownloadTaskRecord, 'id' | 'orgId' | 'createdByUserId' | 'displayName' | 'sourceUri'>
action: string
metadata?: Record<string, unknown>
},
): Promise<void> {
await deps.activity.record({
orgId: input.task.orgId,
userId: input.task.createdByUserId,
action: input.action,
targetType: DOWNLOAD_TASK_TARGET_TYPE,
targetId: input.task.id,
targetName: taskTargetName(input.task),
metadata: input.metadata,
})
}
function taskTargetName(task: Pick<DownloadTaskRecord, 'displayName' | 'sourceUri'>): string {
return task.displayName || task.sourceUri
}
function runtimeMetadata(runtime: DownloadTaskRuntime | null): Record<string, unknown> {
return {
...(runtime?.engine ? { engine: runtime.engine } : {}),
...(runtime?.phase ? { phase: runtime.phase } : {}),
...(runtime?.state ? { engineState: runtime.state } : {}),
...(runtime?.torrent?.infoHash ? { infoHash: runtime.torrent.infoHash } : {}),
...(runtime?.trackers ? { trackerCount: runtime.trackers.length } : {}),
...(runtime?.torrent?.seeders !== undefined ? { seeders: runtime.torrent.seeders } : {}),
...(runtime?.torrent?.peers !== undefined ? { peers: runtime.torrent.peers } : {}),
}
}
function taskLifecycleTimelineItems(task: DownloadTaskRecord): DownloadTaskTimelineItem[] {
const items = [
lifecycleTimelineItem(task, 'download_task_created', task.createdAt),
task.assignedAt && lifecycleTimelineItem(task, 'download_task_assigned', task.assignedAt),
task.startedAt && lifecycleTimelineItem(task, 'download_task_started', task.startedAt),
task.resolveStartedAt && lifecycleTimelineItem(task, 'download_resolve_started', task.resolveStartedAt),
task.resolveCompletedAt && lifecycleTimelineItem(task, 'download_resolve_completed', task.resolveCompletedAt),
task.downloadCompletedAt && lifecycleTimelineItem(task, 'download_completed', task.downloadCompletedAt),
task.ingestStartedAt && lifecycleTimelineItem(task, 'download_ingest_started', task.ingestStartedAt),
task.ingestCompletedAt && lifecycleTimelineItem(task, 'download_ingest_completed', task.ingestCompletedAt),
task.seedingStartedAt && lifecycleTimelineItem(task, 'download_seeding_started', task.seedingStartedAt),
task.seedingStoppedAt && lifecycleTimelineItem(task, 'download_seeding_stopped', task.seedingStoppedAt),
task.finishedAt && lifecycleTimelineItem(task, statusAction(task.status), task.finishedAt),
].filter(Boolean) as DownloadTaskTimelineItem[]
return items
}
function lifecycleTimelineItem(task: DownloadTaskRecord, action: string, time: Date): DownloadTaskTimelineItem {
return {
id: `task:${action}:${time.getTime()}`,
taskId: task.id,
time: time.toISOString(),
source: 'task',
action,
title: actionTitle(action),
detail: null,
severity: actionSeverity(action),
metadata: null,
}
}
function activityTimelineItem(taskId: string, event: ActivityEvent): DownloadTaskTimelineItem {
const metadata = parseActivityMetadata(event.metadata)
return {
id: event.id,
taskId,
time: event.createdAt.toISOString(),
source: 'activity',
action: event.action,
title: actionTitle(event.action),
detail: actionDetail(metadata),
severity: actionSeverity(event.action),
metadata,
}
}
function parseActivityMetadata(value: string | null): Record<string, unknown> | null {
if (!value) return null
try {
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null
} catch {
return null
}
}
function actionTitle(action: string): string {
const titles: Record<string, string> = {
download_task_created: 'Task created',
download_task_assigned: 'Assigned to downloader',
download_task_queued: 'Queued',
download_task_started: 'Download started',
download_task_ingesting: 'Ingesting',
download_task_completed: 'Task completed',
download_task_failed: 'Task failed',
download_task_canceled: 'Task canceled',
download_task_suspended: 'Task suspended',
download_task_paused: 'Task paused',
download_task_pause_requested: 'Pause requested',
download_task_resume_requested: 'Resume requested',
download_task_cancel_requested: 'Cancel requested',
download_task_retry_requested: 'Retry requested',
download_task_restart_requested: 'Restart requested',
download_task_deleted: 'Task deleted',
download_task_error: 'Error reported',
download_task_billing_suspended: 'Billing suspended',
download_resolve_started: 'Resolving source',
download_resolve_completed: 'Source resolved',
download_completed: 'Download completed',
download_ingest_started: 'Ingest started',
download_ingest_completed: 'Ingest completed',
download_seeding_started: 'Seeding started',
download_seeding_stopped: 'Seeding stopped',
download_stale_requeued: 'Requeued after downloader went offline',
download_stale_control_resolved: 'Resolved after downloader went offline',
}
return titles[action] ?? action.replace(/_/g, ' ')
}
function actionDetail(metadata: Record<string, unknown> | null): string | null {
if (!metadata) return null
if (typeof metadata.message === 'string') return metadata.message
if (typeof metadata.reason === 'string') return metadata.reason
const parts = [
typeof metadata.engine === 'string' ? metadata.engine : null,
typeof metadata.phase === 'string' ? metadata.phase : null,
typeof metadata.infoHash === 'string' ? `infoHash ${metadata.infoHash}` : null,
typeof metadata.trackerCount === 'number' ? `${metadata.trackerCount} trackers` : null,
typeof metadata.seeders === 'number' ? `${metadata.seeders} seeders` : null,
typeof metadata.peers === 'number' ? `${metadata.peers} peers` : null,
].filter(Boolean)
if (parts.length > 0) return parts.join(' · ')
if (typeof metadata.from === 'string' && typeof metadata.to === 'string') return `${metadata.from} -> ${metadata.to}`
return null
}
function actionSeverity(action: string): DownloadTaskTimelineItem['severity'] {
if (action.includes('failed') || action.includes('error')) return 'error'
if (action.includes('suspended') || action.includes('offline')) return 'warning'
if (action.includes('completed')) return 'success'
return 'info'
}
function downloadTaskFromRecord(row: DownloadTaskRecord): DownloadTask {
const runtime = parseTaskRuntime(row.runtime)
return {
@@ -767,6 +1130,13 @@ function downloadTaskFromRecord(row: DownloadTaskRecord): DownloadTask {
output: row.resultObjectId ? { objectId: row.resultObjectId } : null,
runtime,
error: row.errorMessage ? { code: row.errorCode, message: row.errorMessage } : null,
resolveStartedAt: row.resolveStartedAt?.toISOString() ?? null,
resolveCompletedAt: row.resolveCompletedAt?.toISOString() ?? null,
downloadCompletedAt: row.downloadCompletedAt?.toISOString() ?? null,
ingestStartedAt: row.ingestStartedAt?.toISOString() ?? null,
ingestCompletedAt: row.ingestCompletedAt?.toISOString() ?? null,
seedingStartedAt: row.seedingStartedAt?.toISOString() ?? null,
seedingStoppedAt: row.seedingStoppedAt?.toISOString() ?? null,
startedAt: row.startedAt?.toISOString() ?? null,
finishedAt: row.finishedAt?.toISOString() ?? null,
updatedAt: row.updatedAt.toISOString(),
+11
View File
@@ -41,6 +41,14 @@ export interface ListAdminAuditOpts {
targetType?: string
}
export interface ListActivityByTargetOpts {
orgId: string
targetType: string
targetId: string
page?: number
pageSize?: number
}
export interface ActivityRepo {
record(event: RecordActivityInput): Promise<void>
list(
@@ -50,4 +58,7 @@ export interface ActivityRepo {
listAdminAudit(
opts: ListAdminAuditOpts,
): Promise<{ items: AdminAuditEventWithOrg[]; total: number; page: number; pageSize: number }>
listByTarget(
opts: ListActivityByTargetOpts,
): Promise<{ items: ActivityEvent[]; total: number; page: number; pageSize: number }>
}
+14
View File
@@ -68,6 +68,13 @@ export interface DownloadTaskRecord {
errorMessage: string | null
resultObjectId: string | null
runtime: string | null
resolveStartedAt: Date | null
resolveCompletedAt: Date | null
downloadCompletedAt: Date | null
ingestStartedAt: Date | null
ingestCompletedAt: Date | null
seedingStartedAt: Date | null
seedingStoppedAt: Date | null
createdAt: Date
updatedAt: Date
assignedAt: Date | null
@@ -208,6 +215,13 @@ export interface UpdateDownloadTaskFields {
errorMessage?: string | null
resultObjectId?: string | null
runtime?: string | null
resolveStartedAt?: Date | null
resolveCompletedAt?: Date | null
downloadCompletedAt?: Date | null
ingestStartedAt?: Date | null
ingestCompletedAt?: Date | null
seedingStartedAt?: Date | null
seedingStoppedAt?: Date | null
assignedAt?: Date | null
startedAt?: Date | null
finishedAt?: Date | null
+31
View File
@@ -145,6 +145,13 @@ export const downloadTaskSchema = z
message: z.string().max(1000).nullable(),
})
.nullable(),
resolveStartedAt: z.string().nullable(),
resolveCompletedAt: z.string().nullable(),
downloadCompletedAt: z.string().nullable(),
ingestStartedAt: z.string().nullable(),
ingestCompletedAt: z.string().nullable(),
seedingStartedAt: z.string().nullable(),
seedingStoppedAt: z.string().nullable(),
startedAt: z.string().nullable(),
finishedAt: z.string().nullable(),
updatedAt: z.string(),
@@ -155,6 +162,30 @@ export const downloadTaskSchema = z
export type DownloadTask = z.infer<typeof downloadTaskSchema>
export const downloadTaskTimelineItemSchema = z
.object({
id: z.string(),
taskId: z.string(),
time: z.string(),
source: z.enum(['task', 'activity']),
action: z.string(),
title: z.string(),
detail: z.string().nullable(),
severity: z.enum(['info', 'success', 'warning', 'error']),
metadata: z.record(z.string(), z.unknown()).nullable(),
})
.openapi('DownloadTaskTimelineItem')
export type DownloadTaskTimelineItem = z.infer<typeof downloadTaskTimelineItemSchema>
export const downloadTaskTimelineSchema = z
.object({
items: z.array(downloadTaskTimelineItemSchema),
})
.openapi('DownloadTaskTimeline')
export type DownloadTaskTimeline = z.infer<typeof downloadTaskTimelineSchema>
export const downloadTaskPageSchema = z
.object({
items: z.array(downloadTaskSchema),
+1
View File
@@ -109,6 +109,7 @@ export {
downloadTaskSchema,
downloadTaskStatusSchema,
downloadTaskStatusUpdateSchema,
downloadTaskTimelineSchema,
listDownloadTasksQuerySchema,
presignObjectUploadPartsSchema,
updateDownloaderCreditBillingSchema,
+8 -1
View File
@@ -240,7 +240,7 @@ export type DownloadTaskBillingState = 'none' | 'ok' | 'insufficient_credits'
// shared/schemas/downloads.ts — one source of truth for the OpenAPI document,
// the generated SDKs, the backend, and the frontend. The sub-interfaces below
// stay as named building blocks the schema mirrors field-for-field.
export type { DownloadTask } from '../schemas/downloads'
export type { DownloadTask, DownloadTaskTimeline, DownloadTaskTimelineItem } from '../schemas/downloads'
export interface DownloadTaskSpec {
source: {
@@ -266,6 +266,13 @@ export interface DownloadTaskExecutionStatus {
output: DownloadTaskOutput | null
runtime: DownloadTaskRuntime | null
error: DownloadTaskError | null
resolveStartedAt: string | null
resolveCompletedAt: string | null
downloadCompletedAt: string | null
ingestStartedAt: string | null
ingestCompletedAt: string | null
seedingStartedAt: string | null
seedingStoppedAt: string | null
startedAt: string | null
finishedAt: string | null
updatedAt: string
+30 -2
View File
@@ -231,7 +231,7 @@
"downloads.detail.tabs.trackers": "Trackers",
"downloads.detail.tabs.peers": "Peers",
"downloads.detail.tabs.files": "Files",
"downloads.detail.tabs.log": "Log",
"downloads.detail.tabs.events": "Events",
"downloads.detail.noSelection": "Select a download task to inspect it",
"downloads.detail.target": "Target folder",
"downloads.detail.category": "Category",
@@ -273,7 +273,35 @@
"downloads.detail.selected": "Selected",
"downloads.detail.skipped": "Skipped",
"downloads.detail.errorMessage": "Error",
"downloads.detail.noLog": "No log yet",
"downloads.detail.eventsLoading": "Loading events...",
"downloads.detail.noEvents": "No events yet",
"downloads.events.download_task_created": "Task created",
"downloads.events.download_task_assigned": "Assigned to downloader",
"downloads.events.download_task_queued": "Queued",
"downloads.events.download_task_started": "Download started",
"downloads.events.download_task_ingesting": "Ingesting",
"downloads.events.download_task_completed": "Task completed",
"downloads.events.download_task_failed": "Task failed",
"downloads.events.download_task_canceled": "Task canceled",
"downloads.events.download_task_suspended": "Task suspended",
"downloads.events.download_task_paused": "Task paused",
"downloads.events.download_task_pause_requested": "Pause requested",
"downloads.events.download_task_resume_requested": "Resume requested",
"downloads.events.download_task_cancel_requested": "Cancel requested",
"downloads.events.download_task_retry_requested": "Retry requested",
"downloads.events.download_task_restart_requested": "Restart requested",
"downloads.events.download_task_deleted": "Task deleted",
"downloads.events.download_task_error": "Error reported",
"downloads.events.download_task_billing_suspended": "Billing suspended",
"downloads.events.download_resolve_started": "Resolving source",
"downloads.events.download_resolve_completed": "Source resolved",
"downloads.events.download_completed": "Download completed",
"downloads.events.download_ingest_started": "Ingest started",
"downloads.events.download_ingest_completed": "Ingest completed",
"downloads.events.download_seeding_started": "Seeding started",
"downloads.events.download_seeding_stopped": "Seeding stopped",
"downloads.events.download_stale_requeued": "Requeued after downloader went offline",
"downloads.events.download_stale_control_resolved": "Resolved after downloader went offline",
"uploadPanel.toggle": "Upload progress",
"uploadPanel.title": "Uploads",
"uploadPanel.empty": "No uploads yet",
+30 -2
View File
@@ -231,7 +231,7 @@
"downloads.detail.tabs.trackers": "Trackers",
"downloads.detail.tabs.peers": "Peers",
"downloads.detail.tabs.files": "文件",
"downloads.detail.tabs.log": "日志",
"downloads.detail.tabs.events": "事件",
"downloads.detail.noSelection": "选择一个下载任务查看详情",
"downloads.detail.target": "目标目录",
"downloads.detail.category": "分类",
@@ -273,7 +273,35 @@
"downloads.detail.selected": "已选择",
"downloads.detail.skipped": "跳过",
"downloads.detail.errorMessage": "错误",
"downloads.detail.noLog": "暂无日志",
"downloads.detail.eventsLoading": "正在加载事件...",
"downloads.detail.noEvents": "暂无事件",
"downloads.events.download_task_created": "任务已创建",
"downloads.events.download_task_assigned": "已分配给下载器",
"downloads.events.download_task_queued": "已排队",
"downloads.events.download_task_started": "下载已开始",
"downloads.events.download_task_ingesting": "正在回传",
"downloads.events.download_task_completed": "任务已完成",
"downloads.events.download_task_failed": "任务失败",
"downloads.events.download_task_canceled": "任务已取消",
"downloads.events.download_task_suspended": "任务已挂起",
"downloads.events.download_task_paused": "任务已暂停",
"downloads.events.download_task_pause_requested": "已请求暂停",
"downloads.events.download_task_resume_requested": "已请求恢复",
"downloads.events.download_task_cancel_requested": "已请求取消",
"downloads.events.download_task_retry_requested": "已请求重试",
"downloads.events.download_task_restart_requested": "已请求重启",
"downloads.events.download_task_deleted": "任务已删除",
"downloads.events.download_task_error": "错误已上报",
"downloads.events.download_task_billing_suspended": "计费挂起",
"downloads.events.download_resolve_started": "开始解析来源",
"downloads.events.download_resolve_completed": "来源解析完成",
"downloads.events.download_completed": "下载完成",
"downloads.events.download_ingest_started": "开始回传",
"downloads.events.download_ingest_completed": "回传完成",
"downloads.events.download_seeding_started": "开始做种",
"downloads.events.download_seeding_stopped": "做种停止",
"downloads.events.download_stale_requeued": "下载器离线后已重新排队",
"downloads.events.download_stale_control_resolved": "下载器离线后已处理控制状态",
"uploadPanel.toggle": "上传进度",
"uploadPanel.title": "上传",
"uploadPanel.empty": "暂无上传任务",
+19
View File
@@ -75,6 +75,7 @@ import {
listCloudProducts,
listCloudStoreTargets,
listDownloaders,
listDownloadTaskEvents,
listDownloadTasks,
listIhostApiKeys,
listIhostImages,
@@ -1146,6 +1147,24 @@ describe('api', () => {
expect(init.body).toBe(JSON.stringify(body))
})
it('lists download task events', async () => {
const payload = { items: [{ id: 'event-1', action: 'download_task_created' }] }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
const result = await listDownloadTaskEvents('task-1')
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/downloads/tasks/task-1/events')
expect(init.method).toBe('GET')
})
it('throws when listing download task events fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'not found' }, false, 404))
await expect(listDownloadTaskEvents('missing')).rejects.toThrow('not found')
})
it('pauses a download task via PUT /status', async () => {
const payload = { id: 'task-1', status: 'paused' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
+5
View File
@@ -42,6 +42,7 @@ import type {
CloudStoreTarget,
Downloader,
DownloadTask,
DownloadTaskTimeline,
IhostConfigResponse,
ImageHosting,
InstanceInfo,
@@ -339,6 +340,10 @@ export function updateDownloadTask(id: string, data: UpdateDownloadTaskInput) {
return unwrap<DownloadTask>(downloadTasksApi[':id'].$patch({ param: { id }, json: data }))
}
export function listDownloadTaskEvents(id: string) {
return unwrap<DownloadTaskTimeline>(downloadTasksApi[':id'].events.$get({ param: { id } }))
}
export function runDownloadTaskAction(id: string, action: DownloadTaskActionInput['action']) {
if (action === 'delete') {
return discard(downloadTasksApi[':id'].$delete({ param: { id } }))
+45 -64
View File
@@ -1,5 +1,11 @@
import { DirType } from '@shared/constants'
import type { DownloadTask, DownloadTaskAction, DownloadTaskStatus, StorageObject } from '@shared/types'
import type {
DownloadTask,
DownloadTaskAction,
DownloadTaskStatus,
DownloadTaskTimelineItem,
StorageObject,
} from '@shared/types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import {
@@ -28,6 +34,7 @@ import {
FolderInput,
Gauge,
GripVertical,
History,
Home,
LinkIcon,
Magnet,
@@ -84,7 +91,7 @@ import { Textarea } from '@/components/ui/textarea'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useServerEventSubscription } from '@/hooks/useServerEvents'
import { createDownloadTask, listDownloadTasks, runDownloadTaskAction } from '@/lib/api'
import { createDownloadTask, listDownloadTaskEvents, listDownloadTasks, runDownloadTaskAction } from '@/lib/api'
import { cn } from '@/lib/utils'
export const Route = createFileRoute('/_authenticated/downloads/')({
@@ -110,7 +117,7 @@ const STATUS_FILTERS: Array<{ value: DownloadTaskStatus | 'all'; labelKey: strin
]
type DownloadTaskDisplayStatus = DownloadTaskStatus | 'seeding'
type DownloadTaskPhase = NonNullable<NonNullable<DownloadTask['status']['runtime']>['phase']>
type DetailTab = 'overview' | 'trackers' | 'peers' | 'files' | 'log'
type DetailTab = 'overview' | 'trackers' | 'peers' | 'files' | 'events'
type PanelDragState = { startY: number; startDetailHeight: number; containerHeight: number }
type PendingTaskAction = { tasks: DownloadTask[]; action: DownloadTaskAction }
type DetailTableColumn<T> = {
@@ -145,7 +152,7 @@ const DETAIL_TABS: Array<{ id: DetailTab; labelKey: string; icon: ReactNode }> =
{ id: 'trackers', labelKey: 'downloads.detail.tabs.trackers', icon: <RadioTower className="size-4" /> },
{ id: 'peers', labelKey: 'downloads.detail.tabs.peers', icon: <Users className="size-4" /> },
{ id: 'files', labelKey: 'downloads.detail.tabs.files', icon: <FileDown className="size-4" /> },
{ id: 'log', labelKey: 'downloads.detail.tabs.log', icon: <AlertCircle className="size-4" /> },
{ id: 'events', labelKey: 'downloads.detail.tabs.events', icon: <History className="size-4" /> },
]
function DownloadsPage() {
@@ -1430,7 +1437,7 @@ function DownloadInspector({
{tab === 'trackers' && <TrackersPanel task={task} />}
{tab === 'peers' && <PeersPanel task={task} />}
{tab === 'files' && <FilesPanel task={task} />}
{tab === 'log' && <LogPanel task={task} />}
{tab === 'events' && <EventsPanel task={task} />}
</div>
</div>
)
@@ -1862,85 +1869,59 @@ function countryCodeToFlag(countryCode: string) {
return String.fromCodePoint(...[...countryCode].map((char) => char.charCodeAt(0) + regionalIndicatorOffset))
}
function LogPanel({ task }: { task: DownloadTask }) {
function EventsPanel({ task }: { task: DownloadTask }) {
const { t } = useTranslation()
const detail = task.status.runtime
const events = [
{
id: 'created',
tone: 'neutral',
time: formatDate(task.createdAt),
title: t('downloads.detail.createdAt'),
detail: sourceUri(task),
},
task.status.startedAt && {
id: 'started',
tone: 'active',
time: formatDate(task.status.startedAt),
title: t('downloads.detail.startedAt'),
detail: [detail?.engine, formatPhase(detail?.phase, t)].filter(Boolean).join(' · '),
},
detail?.message && {
id: 'runtime-message',
tone: 'warning',
time: formatDate(detail.updatedAt),
title: t('downloads.detail.statusMessage'),
detail: detail.message,
},
task.status.error?.message && {
id: 'error',
tone: 'error',
time: formatDate(task.status.updatedAt),
title: t('downloads.detail.errorMessage'),
detail: task.status.error.message,
},
task.status.finishedAt && {
id: 'finished',
tone: task.status.state === 'completed' ? 'success' : 'neutral',
time: formatDate(task.status.finishedAt),
title: t('downloads.detail.finishedAt'),
detail: t(`downloads.status.${task.status.state}`),
},
].filter(Boolean) as Array<{
id: string
tone: 'active' | 'error' | 'neutral' | 'success' | 'warning'
time: string
title: string
detail: string
}>
const timelineEvents = [...events].reverse()
const eventsQuery = useQuery({
queryKey: ['download-task-events', task.id],
queryFn: () => listDownloadTaskEvents(task.id),
})
const events = eventsQuery.data?.items ?? []
return (
<div className="space-y-0 text-xs">
{timelineEvents.map((event, index) => (
{events.map((event, index) => (
<div key={event.id} className="grid grid-cols-[1.25rem_1fr] gap-2">
<div className="relative flex justify-center">
<span className={cn('mt-1.5 size-2 rounded-full', logEventDotClass(event.tone))} />
{index < timelineEvents.length - 1 && <span className="absolute top-4 bottom-0 w-px bg-border" />}
<span className={cn('mt-1.5 size-2 rounded-full', eventDotClass(event.severity))} />
{index < events.length - 1 && <span className="absolute top-4 bottom-0 w-px bg-border" />}
</div>
<div className="min-w-0 pb-3">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="font-mono text-[11px] text-muted-foreground">{event.time}</span>
<span className="font-medium">{event.title}</span>
<span className="font-mono text-[11px] text-muted-foreground">{formatDate(event.time)}</span>
<span className="font-medium">{timelineTitle(event, t)}</span>
</div>
{event.detail && <div className="mt-0.5 break-words text-muted-foreground">{event.detail}</div>}
{timelineDetail(event, t) && (
<div className="mt-0.5 break-words text-muted-foreground">{timelineDetail(event, t)}</div>
)}
</div>
</div>
))}
{events.length === 0 && <EmptyPanel text={t('downloads.detail.noLog')} />}
{eventsQuery.isLoading && <EmptyPanel text={t('downloads.detail.eventsLoading')} />}
{!eventsQuery.isLoading && events.length === 0 && <EmptyPanel text={t('downloads.detail.noEvents')} />}
</div>
)
}
function logEventDotClass(tone: 'active' | 'error' | 'neutral' | 'success' | 'warning') {
if (tone === 'active') return 'bg-sky-500'
if (tone === 'error') return 'bg-destructive'
if (tone === 'success') return 'bg-emerald-500'
if (tone === 'warning') return 'bg-amber-500'
function eventDotClass(severity: DownloadTaskTimelineItem['severity']) {
if (severity === 'error') return 'bg-destructive'
if (severity === 'success') return 'bg-emerald-500'
if (severity === 'warning') return 'bg-amber-500'
return 'bg-muted-foreground/50'
}
function timelineTitle(event: DownloadTaskTimelineItem, t: ReturnType<typeof useTranslation>['t']) {
const key = `downloads.events.${event.action}`
const translated = t(key)
return translated === key ? event.title : translated
}
function timelineDetail(event: DownloadTaskTimelineItem, t: ReturnType<typeof useTranslation>['t']) {
if (event.action === 'download_task_started' && typeof event.metadata?.phase === 'string') {
return formatPhase(event.metadata.phase as DownloadTaskPhase, t)
}
return event.detail
}
function SourceIcon({ type }: { type: DownloadTask['spec']['source']['type'] }) {
if (type === 'magnet') return <Magnet className="size-4 shrink-0 text-amber-500" />
if (type === 'torrent_url') return <FileDown className="size-4 shrink-0 text-violet-500" />