mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
feat(downloads): add task event timeline
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -109,6 +109,7 @@ export {
|
||||
downloadTaskSchema,
|
||||
downloadTaskStatusSchema,
|
||||
downloadTaskStatusUpdateSchema,
|
||||
downloadTaskTimelineSchema,
|
||||
listDownloadTasksQuerySchema,
|
||||
presignObjectUploadPartsSchema,
|
||||
updateDownloaderCreditBillingSchema,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "暂无上传任务",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 } }))
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user