Add a runs screen: a table, a run in full, and curves side by side
This commit is contained in:
@@ -14,9 +14,11 @@ from fluksio.flow.dashboards import (
|
|||||||
DashboardNotFound,
|
DashboardNotFound,
|
||||||
DashboardsPublic,
|
DashboardsPublic,
|
||||||
default_dashboard,
|
default_dashboard,
|
||||||
|
results_dashboard,
|
||||||
|
results_name,
|
||||||
)
|
)
|
||||||
from fluksio.flow.events import event_bus
|
from fluksio.flow.events import event_bus
|
||||||
from fluksio.flow.store import StaleVersion
|
from fluksio.flow.store import FlowNotFound, StaleVersion
|
||||||
from fluksio.models import Message
|
from fluksio.models import Message
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -73,6 +75,40 @@ async def create_dashboard(name: str, store: DashboardStoreDep) -> Any:
|
|||||||
return await run_in_threadpool(store.write_draft, defn, 0)
|
return await run_in_threadpool(store.write_draft, defn, 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/from-flow/{flow}", response_model=DashboardDef)
|
||||||
|
async def generate_results_dashboard(
|
||||||
|
flow: str,
|
||||||
|
store: DashboardStoreDep,
|
||||||
|
controller: FlowControllerDep,
|
||||||
|
) -> Any:
|
||||||
|
"""Draw a batch flow's results as a dashboard, from the ports it declares.
|
||||||
|
|
||||||
|
Published straight away rather than left as a draft: there is nothing to
|
||||||
|
review that the flow did not already say, and what makes it useful is
|
||||||
|
being able to open it against a run immediately. It is an ordinary
|
||||||
|
dashboard afterwards — editing it is how it stops being generic.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
definition = await run_in_threadpool(controller.store.read_flow, flow)
|
||||||
|
except FlowNotFound:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No flow named '{flow}'")
|
||||||
|
if definition.mode != "batch":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail=f"'{flow}' is a live flow; a results dashboard is a run's view",
|
||||||
|
)
|
||||||
|
name = results_name(flow)
|
||||||
|
if await run_in_threadpool(store.exists, name):
|
||||||
|
raise HTTPException(status_code=409, detail=f"'{name}' already exists")
|
||||||
|
|
||||||
|
written = await run_in_threadpool(store.write, results_dashboard(definition))
|
||||||
|
await run_in_threadpool(_apply_history_limits, store, controller)
|
||||||
|
event_bus.publish(
|
||||||
|
{"type": "dashboard_changed", "dashboard": name, "ts": time.time()}
|
||||||
|
)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{name}", response_model=DashboardDef)
|
@router.put("/{name}", response_model=DashboardDef)
|
||||||
async def save_dashboard(
|
async def save_dashboard(
|
||||||
name: str,
|
name: str,
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ from typing import Any, Literal
|
|||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
from fluksio.flow.schemas import _validate_name
|
from fluksio.flow.messages import DType, qualify
|
||||||
|
from fluksio.flow.schemas import FlowDef, _validate_name
|
||||||
from fluksio.flow.store import FlowStore, StaleVersion
|
from fluksio.flow.store import FlowStore, StaleVersion
|
||||||
|
|
||||||
#: Sibling of the shared-node library, and likewise not a flow.
|
#: Sibling of the shared-node library, and likewise not a flow.
|
||||||
@@ -716,6 +717,105 @@ def default_dashboard(name: str) -> DashboardDef:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: What the generated dashboard is called, for a flow of this name.
|
||||||
|
def results_name(flow: str) -> str:
|
||||||
|
return f"{flow}_results"
|
||||||
|
|
||||||
|
|
||||||
|
#: Numbers a stat or a chart can draw.
|
||||||
|
_NUMERIC = {DType.FLOAT, DType.INT}
|
||||||
|
|
||||||
|
|
||||||
|
def results_dashboard(flow: FlowDef) -> DashboardDef:
|
||||||
|
"""A results dashboard for a batch flow, from the ports it declares.
|
||||||
|
|
||||||
|
A streaming output is a curve and gets a chart; a scalar output is a
|
||||||
|
number and gets a stat. Nothing here is specific to runs: the widgets bind
|
||||||
|
to the flow's own message names, which is what makes the same page draw a
|
||||||
|
run live, draw a finished one when opened in a run's context, and stay an
|
||||||
|
ordinary dashboard anyone can edit afterwards.
|
||||||
|
|
||||||
|
A starting point rather than a finished page — which is the only reason
|
||||||
|
generating one is worth doing at all.
|
||||||
|
"""
|
||||||
|
charts = [
|
||||||
|
spec
|
||||||
|
for node in flow.nodes
|
||||||
|
for spec in node.provides
|
||||||
|
if spec.stream and spec.dtype in _NUMERIC and spec.name
|
||||||
|
]
|
||||||
|
# What a run reports. Declared outputs are unqualified names; an empty list
|
||||||
|
# means "everything the flow ends up holding", which is not a set this can
|
||||||
|
# enumerate, so it draws no stats rather than guessing at them.
|
||||||
|
produced = {
|
||||||
|
spec.name.rsplit(".", 1)[-1]: spec
|
||||||
|
for node in flow.nodes
|
||||||
|
for spec in node.provides
|
||||||
|
if spec.name
|
||||||
|
}
|
||||||
|
stats = [
|
||||||
|
produced[name]
|
||||||
|
for name in flow.outputs
|
||||||
|
if name in produced and not produced[name].stream
|
||||||
|
]
|
||||||
|
|
||||||
|
widgets: list[WidgetDef] = []
|
||||||
|
for index, spec in enumerate(charts):
|
||||||
|
widgets.append(
|
||||||
|
WidgetDef(
|
||||||
|
id=f"chart_{spec.port or index}",
|
||||||
|
type="chart",
|
||||||
|
title=spec.port or spec.name,
|
||||||
|
layout={"lg": Placement(x=0, y=index * 4, w=8, h=4)},
|
||||||
|
config={
|
||||||
|
"series": [
|
||||||
|
{
|
||||||
|
"message": qualify(flow.name, spec.name),
|
||||||
|
"dtype": spec.dtype.value,
|
||||||
|
"label": spec.port or spec.name,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"history": {"points": 600},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
row = 0
|
||||||
|
for spec in stats:
|
||||||
|
if spec.dtype is DType.RECORD:
|
||||||
|
kind: WidgetType = "notification"
|
||||||
|
elif spec.dtype in _NUMERIC or spec.dtype is DType.STR:
|
||||||
|
kind = "stat"
|
||||||
|
else:
|
||||||
|
# A list, a series or an artifact has no single reading to show.
|
||||||
|
continue
|
||||||
|
widgets.append(
|
||||||
|
WidgetDef(
|
||||||
|
id=f"out_{spec.port}",
|
||||||
|
type=kind,
|
||||||
|
title=spec.port,
|
||||||
|
layout={"lg": Placement(x=8, y=row * 2, w=4, h=2)},
|
||||||
|
config={
|
||||||
|
"message": qualify(flow.name, spec.name),
|
||||||
|
"dtype": spec.dtype.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
return DashboardDef(
|
||||||
|
name=results_name(flow.name),
|
||||||
|
title=f"{flow.title or flow.name} results",
|
||||||
|
pages=[
|
||||||
|
PageDef(
|
||||||
|
id="main",
|
||||||
|
title="Results",
|
||||||
|
sections=[SectionDef(id="main", widgets=widgets)],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BAR_ROWS",
|
"BAR_ROWS",
|
||||||
"COLOR_DTYPES",
|
"COLOR_DTYPES",
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ from fluksio.flow.dashboards import (
|
|||||||
SettingDef,
|
SettingDef,
|
||||||
WidgetDef,
|
WidgetDef,
|
||||||
default_dashboard,
|
default_dashboard,
|
||||||
|
results_dashboard,
|
||||||
)
|
)
|
||||||
from fluksio.flow.messages import DType, MessageSpec
|
from fluksio.flow.messages import DType, MessageSpec
|
||||||
from fluksio.flow.nodes import Node
|
from fluksio.flow.nodes import Node
|
||||||
from fluksio.flow.pipeline import Pipeline
|
from fluksio.flow.pipeline import Pipeline
|
||||||
|
from fluksio.flow.schemas import FlowDef, NodeDef
|
||||||
from fluksio.flow.state import MemoryState
|
from fluksio.flow.state import MemoryState
|
||||||
from fluksio.flow.store import FlowStore, StaleVersion
|
from fluksio.flow.store import FlowStore, StaleVersion
|
||||||
|
|
||||||
@@ -444,3 +446,50 @@ def test_a_bound_setting_is_drawn_on_the_canvas(store: DashboardStore):
|
|||||||
assert binding["requires"] == ["home.theme"]
|
assert binding["requires"] == ["home.theme"]
|
||||||
assert not binding["provides"]
|
assert not binding["provides"]
|
||||||
assert store.bindings_for("other") == []
|
assert store.bindings_for("other") == []
|
||||||
|
|
||||||
|
|
||||||
|
def training_flow() -> FlowDef:
|
||||||
|
"""A batch flow shaped like an experiment: a curve and two results."""
|
||||||
|
return FlowDef(
|
||||||
|
name="study",
|
||||||
|
mode="batch",
|
||||||
|
outputs=["accuracy", "report"],
|
||||||
|
nodes=[
|
||||||
|
NodeDef(
|
||||||
|
id="fit",
|
||||||
|
provides=[
|
||||||
|
MessageSpec(name="loss", dtype=DType.FLOAT, stream=True),
|
||||||
|
MessageSpec(name="accuracy", dtype=DType.FLOAT),
|
||||||
|
MessageSpec(name="report", dtype=DType.RECORD),
|
||||||
|
MessageSpec(name="weights", dtype=DType.ARTIFACT),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_generated_dashboard_charts_the_curves_and_states_the_results():
|
||||||
|
"""The ports are the whole specification; nothing else is guessed."""
|
||||||
|
defn = results_dashboard(training_flow())
|
||||||
|
kinds = [(w.type, w.config.get("message")) for w in defn.widgets]
|
||||||
|
|
||||||
|
assert [w.type for w in defn.widgets] == ["chart", "stat", "notification"]
|
||||||
|
assert defn.widgets[0].config["series"][0]["message"] == "study.loss"
|
||||||
|
assert ("stat", "study.accuracy") in kinds
|
||||||
|
assert ("notification", "study.report") in kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_artifact_output_gets_no_widget():
|
||||||
|
"""A checkpoint has no single reading to draw."""
|
||||||
|
defn = results_dashboard(
|
||||||
|
training_flow().model_copy(update={"outputs": ["weights"]})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [w.type for w in defn.widgets] == ["chart"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_flow_declaring_no_outputs_draws_no_stats():
|
||||||
|
"""Empty outputs means "everything", which is not a set to enumerate."""
|
||||||
|
defn = results_dashboard(training_flow().model_copy(update={"outputs": []}))
|
||||||
|
|
||||||
|
assert [w.type for w in defn.widgets] == ["chart"]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { CancelablePromise } from './core/CancelablePromise';
|
import type { CancelablePromise } from './core/CancelablePromise';
|
||||||
import { OpenAPI } from './core/OpenAPI';
|
import { OpenAPI } from './core/OpenAPI';
|
||||||
import { request as __request } from './core/request';
|
import { request as __request } from './core/request';
|
||||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
|
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudAddRemoteUserData, CloudAddRemoteUserResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsGenerateResultsDashboardData, DashboardsGenerateResultsDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsAcknowledgeNodeErrorData, FlowsAcknowledgeNodeErrorResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, ModulesRefreshModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsPendingDeviceData, PanelsPendingDeviceResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsUnpairPanelData, PanelsUnpairPanelResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadOverviewResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
|
||||||
|
|
||||||
export class AlertsService {
|
export class AlertsService {
|
||||||
/**
|
/**
|
||||||
@@ -307,6 +307,32 @@ export class DashboardsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate Results Dashboard
|
||||||
|
* Draw a batch flow's results as a dashboard, from the ports it declares.
|
||||||
|
*
|
||||||
|
* Published straight away rather than left as a draft: there is nothing to
|
||||||
|
* review that the flow did not already say, and what makes it useful is
|
||||||
|
* being able to open it against a run immediately. It is an ordinary
|
||||||
|
* dashboard afterwards — editing it is how it stops being generic.
|
||||||
|
* @param data The data for the request.
|
||||||
|
* @param data.flow
|
||||||
|
* @returns DashboardDef_Output Successful Response
|
||||||
|
* @throws ApiError
|
||||||
|
*/
|
||||||
|
public static generateResultsDashboard(data: DashboardsGenerateResultsDashboardData): CancelablePromise<DashboardsGenerateResultsDashboardResponse> {
|
||||||
|
return __request(OpenAPI, {
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/dashboards/from-flow/{flow}',
|
||||||
|
path: {
|
||||||
|
flow: data.flow
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
422: 'Validation Error'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish Dashboard
|
* Publish Dashboard
|
||||||
* Put the unpublished changes on the panels.
|
* Put the unpublished changes on the panels.
|
||||||
|
|||||||
@@ -1236,6 +1236,12 @@ export type DashboardsDeleteDashboardData = {
|
|||||||
|
|
||||||
export type DashboardsDeleteDashboardResponse = (Message);
|
export type DashboardsDeleteDashboardResponse = (Message);
|
||||||
|
|
||||||
|
export type DashboardsGenerateResultsDashboardData = {
|
||||||
|
flow: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardsGenerateResultsDashboardResponse = (DashboardDef_Output);
|
||||||
|
|
||||||
export type DashboardsPublishDashboardData = {
|
export type DashboardsPublishDashboardData = {
|
||||||
name: string;
|
name: string;
|
||||||
requestBody: fluksio__api__routes__dashboards__PublishRequest;
|
requestBody: fluksio__api__routes__dashboards__PublishRequest;
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export function UplotChart({
|
|||||||
yLabel,
|
yLabel,
|
||||||
palette,
|
palette,
|
||||||
smooth = false,
|
smooth = false,
|
||||||
|
xTime = true,
|
||||||
onCursor,
|
onCursor,
|
||||||
onSelect,
|
onSelect,
|
||||||
}: {
|
}: {
|
||||||
@@ -262,6 +263,10 @@ export function UplotChart({
|
|||||||
* Monotone rather than plain cubic on purpose: a spline that overshoots
|
* Monotone rather than plain cubic on purpose: a spline that overshoots
|
||||||
* invents readings between two the sensor actually took. */
|
* invents readings between two the sensor actually took. */
|
||||||
smooth?: boolean
|
smooth?: boolean
|
||||||
|
/** The x axis reads as time. False when x is a count rather than a moment —
|
||||||
|
* a run's metric is indexed by step, and drawn as time it would date every
|
||||||
|
* point to 1970. */
|
||||||
|
xTime?: boolean
|
||||||
/** The x value under the pointer, and null once it leaves the plot. */
|
/** The x value under the pointer, and null once it leaves the plot. */
|
||||||
onCursor?: (ts: number | null) => void
|
onCursor?: (ts: number | null) => void
|
||||||
/** The x value clicked, or null for a click that landed on no point. */
|
/** The x value clicked, or null for a click that landed on no point. */
|
||||||
@@ -284,7 +289,7 @@ export function UplotChart({
|
|||||||
// while a new reading only sets its data. The unit, the fixed range and the
|
// while a new reading only sets its data. The unit, the fixed range and the
|
||||||
// axis title are part of it — all are baked into the axes at build time —
|
// axis title are part of it — all are baked into the axes at build time —
|
||||||
// and so are the line shape and the palette, which the series close over.
|
// and so are the line shape and the palette, which the series close over.
|
||||||
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}`
|
const key = `${labels.join(" ")}|${unit ?? ""}|${yRange?.join(",") ?? ""}|${yLabel ?? ""}|${smooth}|${palette?.join("") ?? ""}|${xTime}`
|
||||||
// uPlot leaves its axes half-initialised while the scales have no range, and
|
// uPlot leaves its axes half-initialised while the scales have no range, and
|
||||||
// a resize in that window (a card still settling, say) draws them anyway and
|
// a resize in that window (a card still settling, say) draws them anyway and
|
||||||
// throws. Waiting for the first reading avoids the state altogether.
|
// throws. Waiting for the first reading avoids the state altogether.
|
||||||
@@ -344,7 +349,7 @@ export function UplotChart({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: { time: true },
|
x: { time: xTime },
|
||||||
...(yRange ? { y: { range: yRange } } : {}),
|
...(yRange ? { y: { range: yRange } } : {}),
|
||||||
},
|
},
|
||||||
axes: [
|
axes: [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect } from "react"
|
|||||||
|
|
||||||
import { OpenAPI } from "@/client"
|
import { OpenAPI } from "@/client"
|
||||||
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
|
||||||
|
import { runKeys } from "@/components/Runs/queries"
|
||||||
import { connectionStore } from "@/lib/connectionStore"
|
import { connectionStore } from "@/lib/connectionStore"
|
||||||
import { apiToken } from "@/lib/portal"
|
import { apiToken } from "@/lib/portal"
|
||||||
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
|
||||||
@@ -89,6 +90,14 @@ type FlowEvent =
|
|||||||
paused?: string[]
|
paused?: string[]
|
||||||
}
|
}
|
||||||
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
|
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
|
||||||
|
| {
|
||||||
|
type: "run_started" | "run_finished"
|
||||||
|
flow: string
|
||||||
|
run: string
|
||||||
|
status?: string
|
||||||
|
group?: string
|
||||||
|
ts?: number
|
||||||
|
}
|
||||||
|
|
||||||
function socketUrl(): string {
|
function socketUrl(): string {
|
||||||
const base = String(OpenAPI.BASE || window.location.origin)
|
const base = String(OpenAPI.BASE || window.location.origin)
|
||||||
@@ -251,6 +260,13 @@ function connect() {
|
|||||||
: panelKeys.all,
|
: panelKeys.all,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
case "run_started":
|
||||||
|
case "run_finished":
|
||||||
|
// One invalidation covers the lot: the list, the flow counts, the run
|
||||||
|
// being watched, and any chart drawing a run's curve. A run is not a
|
||||||
|
// live value, so nothing here goes through the live store.
|
||||||
|
client?.invalidateQueries({ queryKey: runKeys.all })
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import type { HistoryPoint } from "@/client"
|
||||||
|
import { MAX_SERIES, UplotChart } from "@/components/Common/UplotChart"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { compareQueryOptions, runMetricsQueryOptions, shortId } from "./queries"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a finished run can have nothing to draw.
|
||||||
|
*
|
||||||
|
* A cache hit restores what a node returned, not the values it emitted along
|
||||||
|
* the way, so a run whose training node was reused has a result and no curve.
|
||||||
|
* Said here rather than left as an empty chart, which reads as a fault.
|
||||||
|
*/
|
||||||
|
export const NO_CURVE =
|
||||||
|
"No curve was recorded. A node restored from the cache replays no emissions, so a run that reused an earlier one draws nothing here — its outputs are still on the result."
|
||||||
|
|
||||||
|
/** The metrics one run recorded, in the order they are worth offering. */
|
||||||
|
export function useMetricNames(runId: string | undefined) {
|
||||||
|
const { data } = useQuery({
|
||||||
|
...runMetricsQueryOptions(runId ?? "", ""),
|
||||||
|
enabled: Boolean(runId),
|
||||||
|
})
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const point of data ?? []) if (point.name) names.add(point.name)
|
||||||
|
return [...names]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One metric across one or more runs, drawn on a step axis.
|
||||||
|
*
|
||||||
|
* The endpoint answers in the chart widget's own series shape, so comparing
|
||||||
|
* three curves and showing one are the same call with a different id list.
|
||||||
|
*/
|
||||||
|
export function RunMetricChart({
|
||||||
|
ids,
|
||||||
|
metric,
|
||||||
|
refreshMs,
|
||||||
|
}: {
|
||||||
|
ids: string[]
|
||||||
|
metric: string
|
||||||
|
refreshMs?: number
|
||||||
|
}) {
|
||||||
|
const { data, isPending } = useQuery(
|
||||||
|
compareQueryOptions(ids, metric, refreshMs),
|
||||||
|
)
|
||||||
|
const lines = (data?.lines ?? []).slice(0, MAX_SERIES)
|
||||||
|
const labels = lines.map((line) =>
|
||||||
|
// The endpoint labels a line with the whole run id, which is too long to
|
||||||
|
// read in a legend beside four others.
|
||||||
|
line.label.replace(/^\S+/, (id) => shortId(id)),
|
||||||
|
)
|
||||||
|
const plots: HistoryPoint[][] = lines.map((line) =>
|
||||||
|
(line.points ?? []).map(([step, value]) => ({ ts: step, value })),
|
||||||
|
)
|
||||||
|
const drawn = plots.reduce((total, plot) => total + plot.length, 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<UplotChart
|
||||||
|
labels={labels}
|
||||||
|
plots={plots}
|
||||||
|
xTime={false}
|
||||||
|
yLabel={metric}
|
||||||
|
pending={isPending}
|
||||||
|
empty={NO_CURVE}
|
||||||
|
/>
|
||||||
|
{drawn > 0 && (data?.lines?.length ?? 0) > MAX_SERIES && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Showing {MAX_SERIES} of {data?.lines?.length} runs.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The metric picker both the detail and the comparison sit under. */
|
||||||
|
export function MetricPicker({
|
||||||
|
names,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
names: string[]
|
||||||
|
value: string
|
||||||
|
onChange: (name: string) => void
|
||||||
|
}) {
|
||||||
|
if (names.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="h-8 w-56">
|
||||||
|
<SelectValue placeholder="Metric" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{names.map((name) => (
|
||||||
|
<SelectItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { LayoutDashboard } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { DashboardsService } from "@/client"
|
||||||
|
import {
|
||||||
|
dashboardKeys,
|
||||||
|
dashboardsQueryOptions,
|
||||||
|
} from "@/components/Dashboard/queries"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
|
||||||
|
/** What the generator calls a flow's results dashboard. Mirrors the backend. */
|
||||||
|
const resultsName = (flow: string) => `${flow}_results`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send these runs to a dashboard.
|
||||||
|
*
|
||||||
|
* The dashboard does the drawing; this only says which one and against what.
|
||||||
|
* A flow with no results dashboard yet is offered one built from its own
|
||||||
|
* declared ports, which is the shortest path from "I ran something" to "I can
|
||||||
|
* see it" — and an ordinary dashboard afterwards.
|
||||||
|
*/
|
||||||
|
export function OpenInDashboard({
|
||||||
|
flow,
|
||||||
|
ids,
|
||||||
|
}: {
|
||||||
|
flow?: string
|
||||||
|
ids: string[]
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const client = useQueryClient()
|
||||||
|
const { showErrorToast } = useCustomToast()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const { data } = useQuery(dashboardsQueryOptions())
|
||||||
|
|
||||||
|
const show = (name: string) => {
|
||||||
|
setOpen(false)
|
||||||
|
navigate({
|
||||||
|
to: "/view/$name",
|
||||||
|
params: { name },
|
||||||
|
search: { runs: ids.join(",") },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const generate = useMutation({
|
||||||
|
mutationFn: (name: string) =>
|
||||||
|
DashboardsService.generateResultsDashboard({ flow: name }),
|
||||||
|
onSuccess: (made) => {
|
||||||
|
client.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||||
|
show(made.name)
|
||||||
|
},
|
||||||
|
onError: (error: { status?: number }) => {
|
||||||
|
// Someone else made it between the listing and the click; it is the one
|
||||||
|
// that was wanted either way.
|
||||||
|
if (error.status === 409 && flow) return show(resultsName(flow))
|
||||||
|
showErrorToast("Could not build a results dashboard for this flow")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const dashboards = data?.data ?? []
|
||||||
|
const results = flow ? resultsName(flow) : ""
|
||||||
|
const hasResults = dashboards.some((one) => one.name === results)
|
||||||
|
|
||||||
|
if (ids.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8">
|
||||||
|
<LayoutDashboard className="mr-1 size-3.5" />
|
||||||
|
Open in dashboard
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-64 p-1">
|
||||||
|
{flow && !hasResults && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => generate.mutate(flow)}
|
||||||
|
disabled={generate.isPending}
|
||||||
|
className="w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{generate.isPending
|
||||||
|
? "Building…"
|
||||||
|
: `Build a results dashboard for ${flow}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{dashboards.map((one) => (
|
||||||
|
<button
|
||||||
|
key={one.name}
|
||||||
|
type="button"
|
||||||
|
onClick={() => show(one.name)}
|
||||||
|
className="w-full rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
{one.title || one.name}
|
||||||
|
{one.name === results && (
|
||||||
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
|
results
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{dashboards.length === 0 && !flow && (
|
||||||
|
<p className="px-2 py-1.5 text-muted-foreground text-sm">
|
||||||
|
No dashboards yet.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { ChevronDown, ChevronRight, Download } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import type { ArtifactRow, RunNodeRow } from "@/client"
|
||||||
|
import { ago } from "@/components/Health/queries"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
|
import { cn, dur, si } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
MetricPicker,
|
||||||
|
NO_CURVE,
|
||||||
|
RunMetricChart,
|
||||||
|
useMetricNames,
|
||||||
|
} from "./MetricChart"
|
||||||
|
import { OpenInDashboard } from "./OpenInDashboard"
|
||||||
|
import {
|
||||||
|
CARD,
|
||||||
|
downloadArtifact,
|
||||||
|
isLive,
|
||||||
|
paramText,
|
||||||
|
runQueryOptions,
|
||||||
|
shortCommit,
|
||||||
|
shortId,
|
||||||
|
useCancelRun,
|
||||||
|
} from "./queries"
|
||||||
|
import { NodeStatusBadge, RunStatusBadge, statusReason } from "./RunStatus"
|
||||||
|
|
||||||
|
const LABEL = "text-muted-foreground text-xs"
|
||||||
|
|
||||||
|
export function RunDetail({ id }: { id: string }) {
|
||||||
|
const { data: run, isPending } = useQuery(runQueryOptions(id))
|
||||||
|
const cancel = useCancelRun()
|
||||||
|
const names = useMetricNames(id)
|
||||||
|
const [metric, setMetric] = useState("")
|
||||||
|
|
||||||
|
if (isPending || !run) return <Skeleton className="h-96 w-full rounded-lg" />
|
||||||
|
|
||||||
|
const shown = metric && names.includes(metric) ? metric : (names[0] ?? "")
|
||||||
|
const nodes = run.nodes ?? []
|
||||||
|
const artifacts = run.artifacts ?? []
|
||||||
|
const reason = statusReason(run)
|
||||||
|
// A run whose nodes were all restored emits nothing, so an empty chart is
|
||||||
|
// the expected outcome rather than a fault. Said once, where it applies.
|
||||||
|
const cached = nodes.some((node) => node.status === "cached")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<header className="flex flex-wrap items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/runs"
|
||||||
|
search={{ flow: run.flow }}
|
||||||
|
className="text-muted-foreground text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{run.flow}
|
||||||
|
</Link>
|
||||||
|
<h1 className="font-mono font-semibold text-xl">{shortId(run.id)}</h1>
|
||||||
|
<RunStatusBadge run={run} />
|
||||||
|
{run.group_id && (
|
||||||
|
<Link
|
||||||
|
to="/runs"
|
||||||
|
search={{ flow: run.flow, group: run.group_id }}
|
||||||
|
className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
in a sweep
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<OpenInDashboard flow={run.flow} ids={[run.id]} />
|
||||||
|
{isLive(run.status) && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => cancel.mutate(run.id)}
|
||||||
|
disabled={cancel.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{reason && <p className="text-muted-foreground text-sm">{reason}</p>}
|
||||||
|
|
||||||
|
<section className={cn(CARD, "grid gap-4 sm:grid-cols-2 lg:grid-cols-4")}>
|
||||||
|
<Fact label="Submitted">{ago(String(run.created_at ?? ""))}</Fact>
|
||||||
|
<Fact label="Took">{run.duration_ms ? dur(run.duration_ms) : "—"}</Fact>
|
||||||
|
<Fact label="By">{run.actor || "—"}</Fact>
|
||||||
|
<Fact label="Cause">{run.cause}</Fact>
|
||||||
|
<Fact label="Seed">{run.seed ?? "—"}</Fact>
|
||||||
|
<Fact label="Code">
|
||||||
|
{/* The user's own repository for a code-declared flow; the store's
|
||||||
|
commit is a generated shim and says less. */}
|
||||||
|
<span className="font-mono">
|
||||||
|
{shortCommit(run.origin_commit || run.commit || "") || "—"}
|
||||||
|
</span>
|
||||||
|
</Fact>
|
||||||
|
<Fact label="Labels">{run.labels.join(", ") || "—"}</Fact>
|
||||||
|
<Fact label="Parameters">
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{shortId(run.params_digest || "")}
|
||||||
|
</span>
|
||||||
|
</Fact>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={cn(CARD, "flex flex-col gap-2")}>
|
||||||
|
<h2 className="font-medium text-sm">Parameters</h2>
|
||||||
|
{Object.keys(run.params).length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
This run took its flow's own defaults.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Object.entries(run.params).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex justify-between gap-4 border-border border-b py-1"
|
||||||
|
>
|
||||||
|
<dt className={LABEL}>{key}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">
|
||||||
|
{paramText(value)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{names.length > 0 && (
|
||||||
|
<section className={cn(CARD, "flex flex-col gap-3")}>
|
||||||
|
<header className="flex items-center gap-3">
|
||||||
|
<h2 className="mr-auto font-medium text-sm">Metrics</h2>
|
||||||
|
<MetricPicker names={names} value={shown} onChange={setMetric} />
|
||||||
|
</header>
|
||||||
|
<RunMetricChart ids={[run.id]} metric={shown} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{names.length === 0 && cached && (
|
||||||
|
<p className={cn(CARD, "text-muted-foreground text-sm")}>{NO_CURVE}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(run.result ?? {}).length > 0 && (
|
||||||
|
<section className={cn(CARD, "flex flex-col gap-2")}>
|
||||||
|
<h2 className="font-medium text-sm">Result</h2>
|
||||||
|
<dl className="grid gap-x-6 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Object.entries(run.result ?? {}).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="flex justify-between gap-4 border-border border-b py-1"
|
||||||
|
>
|
||||||
|
<dt className={LABEL}>{key}</dt>
|
||||||
|
<dd className="truncate font-mono text-sm">
|
||||||
|
{paramText(value)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NodesTable nodes={nodes} />
|
||||||
|
|
||||||
|
{artifacts.length > 0 && <Artifacts rows={artifacts} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fact({
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className={LABEL}>{label}</span>
|
||||||
|
<span className="truncate text-sm">{children}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each node did, with its logs and its traceback behind a disclosure. */
|
||||||
|
function NodesTable({ nodes }: { nodes: RunNodeRow[] }) {
|
||||||
|
const [open, setOpen] = useState<string | null>(null)
|
||||||
|
if (nodes.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn(CARD, "overflow-x-auto p-0")}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
<TableHead>Node</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Took</TableHead>
|
||||||
|
<TableHead>Worker</TableHead>
|
||||||
|
<TableHead className="text-right">Attempt</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{nodes.map((node) => {
|
||||||
|
const detail = node.error || node.logs
|
||||||
|
const isOpen = open === node.node
|
||||||
|
return [
|
||||||
|
<TableRow key={node.node}>
|
||||||
|
<TableCell>
|
||||||
|
{detail && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(isOpen ? null : node.node)}
|
||||||
|
aria-label={`Logs for ${node.node}`}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-sm">{node.node}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<NodeStatusBadge status={node.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
|
||||||
|
{node.duration_ms ? dur(node.duration_ms) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{node.worker || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
|
||||||
|
{node.attempt}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>,
|
||||||
|
isOpen && detail ? (
|
||||||
|
<TableRow
|
||||||
|
key={`${node.node}-detail`}
|
||||||
|
className="hover:bg-transparent"
|
||||||
|
>
|
||||||
|
<TableCell colSpan={6} className="bg-muted/30">
|
||||||
|
{node.error && (
|
||||||
|
<pre className="mb-2 overflow-x-auto whitespace-pre-wrap text-destructive text-xs">
|
||||||
|
{node.error}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
{node.logs && (
|
||||||
|
<pre className="max-h-64 overflow-auto whitespace-pre-wrap text-muted-foreground text-xs">
|
||||||
|
{node.logs}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null,
|
||||||
|
]
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Artifacts({ rows }: { rows: ArtifactRow[] }) {
|
||||||
|
const { showErrorToast } = useCustomToast()
|
||||||
|
return (
|
||||||
|
<section className={cn(CARD, "overflow-x-auto p-0")}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead>Artifact</TableHead>
|
||||||
|
<TableHead>From</TableHead>
|
||||||
|
<TableHead>Type</TableHead>
|
||||||
|
<TableHead className="text-right">Size</TableHead>
|
||||||
|
<TableHead className="w-10" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.name}>
|
||||||
|
<TableCell className="font-mono text-sm">{row.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{row.node}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{row.media_type || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
|
||||||
|
{si(row.size)}B
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
aria-label={`Download ${row.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
downloadArtifact(row.digest, row.name).catch(() =>
|
||||||
|
showErrorToast(`Could not download ${row.name}`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How each status is drawn.
|
||||||
|
*
|
||||||
|
* Colour never carries the status on its own — the word is always written
|
||||||
|
* beside it (DESIGN-GUIDELINES.md → status is named in text).
|
||||||
|
*/
|
||||||
|
const LOOKS: Record<string, string> = {
|
||||||
|
ok: "border-transparent bg-status-success/15 text-status-success",
|
||||||
|
running: "border-transparent bg-primary/15 text-primary",
|
||||||
|
queued: "border-border bg-muted text-muted-foreground",
|
||||||
|
error: "border-transparent bg-destructive/15 text-destructive",
|
||||||
|
cancelled: "border-border bg-muted text-muted-foreground",
|
||||||
|
abandoned: "border-transparent bg-destructive/10 text-destructive",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a run is where it is, when the status alone does not say.
|
||||||
|
*
|
||||||
|
* A run that sits queued forever is the one genuinely puzzling state, and its
|
||||||
|
* reason is the answer: no worker carrying the labels it asked for is attached.
|
||||||
|
*/
|
||||||
|
export function statusReason(run: {
|
||||||
|
status: string
|
||||||
|
status_reason: string
|
||||||
|
labels: string[]
|
||||||
|
started_at?: unknown
|
||||||
|
}): string {
|
||||||
|
if (run.status_reason) return run.status_reason
|
||||||
|
if (run.status === "queued" && !run.started_at && run.labels.length)
|
||||||
|
return `Waiting for a worker labelled ${run.labels.join(", ")}`
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunStatusBadge({
|
||||||
|
run,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
run: {
|
||||||
|
status: string
|
||||||
|
status_reason: string
|
||||||
|
labels: string[]
|
||||||
|
started_at?: unknown
|
||||||
|
}
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const reason = statusReason(run)
|
||||||
|
const badge = (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex w-fit shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||||
|
LOOKS[run.status] ?? "border-border text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
if (!reason) return badge
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{badge}</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-xs">{reason}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node's outcome inside a run, where "cached" is its own thing. */
|
||||||
|
export function NodeStatusBadge({ status }: { status: string }) {
|
||||||
|
const look =
|
||||||
|
status === "cached"
|
||||||
|
? "border-border bg-muted text-muted-foreground"
|
||||||
|
: (LOOKS[status] ?? "border-border text-muted-foreground")
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex w-fit shrink-0 items-center rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||||
|
look,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { FlaskConical, X } from "lucide-react"
|
||||||
|
|
||||||
|
import type { fluksio__api__routes__runs__RunRow as RunRow } from "@/client"
|
||||||
|
import { MAX_SERIES } from "@/components/Common/UplotChart"
|
||||||
|
import { ago } from "@/components/Health/queries"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { cn, dur } from "@/lib/utils"
|
||||||
|
import { MetricPicker, RunMetricChart, useMetricNames } from "./MetricChart"
|
||||||
|
import { OpenInDashboard } from "./OpenInDashboard"
|
||||||
|
import {
|
||||||
|
CARD,
|
||||||
|
LIST_CAP,
|
||||||
|
paramsSummary,
|
||||||
|
paramText,
|
||||||
|
runOverviewQueryOptions,
|
||||||
|
runsInfiniteQueryOptions,
|
||||||
|
STATUSES,
|
||||||
|
shortCommit,
|
||||||
|
shortId,
|
||||||
|
varyingKeys,
|
||||||
|
} from "./queries"
|
||||||
|
import { RunStatusBadge } from "./RunStatus"
|
||||||
|
|
||||||
|
export type RunsSearch = {
|
||||||
|
flow?: string
|
||||||
|
status?: string
|
||||||
|
group?: string
|
||||||
|
/** The runs being compared, comma-joined — a comparison is a link. */
|
||||||
|
compare?: string
|
||||||
|
metric?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunsScreen({
|
||||||
|
search,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
search: RunsSearch
|
||||||
|
update: (next: Partial<RunsSearch>) => void
|
||||||
|
}) {
|
||||||
|
const filters = {
|
||||||
|
...(search.flow ? { flow: search.flow } : {}),
|
||||||
|
...(search.status ? { status: search.status } : {}),
|
||||||
|
...(search.group ? { group: search.group } : {}),
|
||||||
|
}
|
||||||
|
const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||||
|
useInfiniteQuery(runsInfiniteQueryOptions(filters))
|
||||||
|
const { data: overview } = useQuery(runOverviewQueryOptions())
|
||||||
|
|
||||||
|
const runs: RunRow[] = data?.pages.flat() ?? []
|
||||||
|
const selected = search.compare ? search.compare.split(",") : []
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
const next = selected.includes(id)
|
||||||
|
? selected.filter((one) => one !== id)
|
||||||
|
: [...selected, id]
|
||||||
|
update({ compare: next.length ? next.join(",") : undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 lg:flex-row">
|
||||||
|
<FlowRail
|
||||||
|
rows={overview ?? []}
|
||||||
|
active={search.flow}
|
||||||
|
onPick={(flow) =>
|
||||||
|
update({ flow, group: undefined, compare: undefined })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex min-w-0 flex-1 flex-col gap-4">
|
||||||
|
<header className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="mr-auto font-semibold text-2xl">
|
||||||
|
{search.flow ?? "Runs"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={search.status ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
update({ status: value === "all" ? undefined : value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-36">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any status</SelectItem>
|
||||||
|
{STATUSES.map((status) => (
|
||||||
|
<SelectItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{search.group && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => update({ group: undefined })}
|
||||||
|
>
|
||||||
|
sweep {shortId(search.group)}
|
||||||
|
<X className="ml-1 size-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<Skeleton className="h-64 w-full rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<RunsTable
|
||||||
|
runs={runs}
|
||||||
|
search={search}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggle}
|
||||||
|
onGroup={(group) => update({ group, compare: undefined })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
disabled={isFetchingNextPage}
|
||||||
|
>
|
||||||
|
{isFetchingNextPage ? "Loading…" : "Load more"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{runs.length} run{runs.length === 1 ? "" : "s"}
|
||||||
|
{!hasNextPage && runs.length >= LIST_CAP
|
||||||
|
? ` — the newest ${LIST_CAP}, which is as deep as this list reads`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected.length > 0 && (
|
||||||
|
<Compare
|
||||||
|
ids={selected}
|
||||||
|
search={search}
|
||||||
|
update={update}
|
||||||
|
flow={runs.find((run) => run.id === selected[0])?.flow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The flows that have runs, which is what an experiment log is indexed by. */
|
||||||
|
function FlowRail({
|
||||||
|
rows,
|
||||||
|
active,
|
||||||
|
onPick,
|
||||||
|
}: {
|
||||||
|
rows: { flow: string; runs: number; running: number; queued: number }[]
|
||||||
|
active?: string
|
||||||
|
onPick: (flow: string | undefined) => void
|
||||||
|
}) {
|
||||||
|
const entry = (
|
||||||
|
key: string,
|
||||||
|
label: string,
|
||||||
|
count: number,
|
||||||
|
busy: number,
|
||||||
|
isActive: boolean,
|
||||||
|
flow: string | undefined,
|
||||||
|
) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(flow)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm",
|
||||||
|
isActive
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||||
|
{busy > 0 && (
|
||||||
|
<span className="rounded-full bg-primary/15 px-1.5 text-primary text-xs">
|
||||||
|
{busy}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs tabular-nums">{count}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex w-full shrink-0 flex-col gap-1 lg:w-56">
|
||||||
|
{entry(
|
||||||
|
"all",
|
||||||
|
"All runs",
|
||||||
|
rows.reduce((total, row) => total + row.runs, 0),
|
||||||
|
rows.reduce((total, row) => total + row.running + row.queued, 0),
|
||||||
|
!active,
|
||||||
|
undefined,
|
||||||
|
)}
|
||||||
|
{rows.map((row) =>
|
||||||
|
entry(
|
||||||
|
row.flow,
|
||||||
|
row.flow,
|
||||||
|
row.runs,
|
||||||
|
row.running + row.queued,
|
||||||
|
active === row.flow,
|
||||||
|
row.flow,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="px-2 py-4 text-muted-foreground text-sm">
|
||||||
|
<FlaskConical className="mb-1 size-4" />
|
||||||
|
<br />
|
||||||
|
Nothing has been run yet. Submit a batch flow from its editor, the CLI
|
||||||
|
or the API and it lands here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunsTable({
|
||||||
|
runs,
|
||||||
|
search,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onGroup,
|
||||||
|
}: {
|
||||||
|
runs: RunRow[]
|
||||||
|
search: RunsSearch
|
||||||
|
selected: string[]
|
||||||
|
onToggle: (id: string) => void
|
||||||
|
onGroup: (group: string) => void
|
||||||
|
}) {
|
||||||
|
// Under a sweep filter the shared parameters say nothing; the two or three
|
||||||
|
// that were swept are the whole point, so they get columns of their own.
|
||||||
|
const varying = search.group ? varyingKeys(runs).slice(0, 4) : []
|
||||||
|
const showFlow = !search.flow
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(CARD, "overflow-x-auto p-0")}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableHead className="w-8" />
|
||||||
|
<TableHead>Run</TableHead>
|
||||||
|
{showFlow && <TableHead>Flow</TableHead>}
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
{varying.length > 0 ? (
|
||||||
|
varying.map((key) => <TableHead key={key}>{key}</TableHead>)
|
||||||
|
) : (
|
||||||
|
<TableHead>Parameters</TableHead>
|
||||||
|
)}
|
||||||
|
<TableHead>Seed</TableHead>
|
||||||
|
<TableHead>Code</TableHead>
|
||||||
|
<TableHead className="text-right">Took</TableHead>
|
||||||
|
<TableHead>Started</TableHead>
|
||||||
|
<TableHead>By</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{runs.length === 0 && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell
|
||||||
|
colSpan={10}
|
||||||
|
className="h-24 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No runs match this filter.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{runs.map((run) => (
|
||||||
|
<TableRow key={run.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(run.id)}
|
||||||
|
onCheckedChange={() => onToggle(run.id)}
|
||||||
|
aria-label={`Compare ${run.id}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/runs/$id"
|
||||||
|
params={{ id: run.id }}
|
||||||
|
className="font-mono text-sm hover:underline"
|
||||||
|
>
|
||||||
|
{shortId(run.id)}
|
||||||
|
</Link>
|
||||||
|
{run.group_id && !search.group && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onGroup(run.group_id as string)}
|
||||||
|
className="rounded-full border border-border px-1.5 text-muted-foreground text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
sweep
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{showFlow && (
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{run.flow}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell>
|
||||||
|
<RunStatusBadge run={run} />
|
||||||
|
</TableCell>
|
||||||
|
{varying.length > 0 ? (
|
||||||
|
varying.map((key) => (
|
||||||
|
<TableCell key={key} className="font-mono text-sm">
|
||||||
|
{paramText(run.params[key])}
|
||||||
|
</TableCell>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableCell className="max-w-64 truncate font-mono text-muted-foreground text-xs">
|
||||||
|
{paramsSummary(run.params) || "—"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
||||||
|
{run.seed ?? "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-muted-foreground text-xs">
|
||||||
|
{/* The user's own repository when there is one: for a flow
|
||||||
|
declared in code, the store's commit names a generated
|
||||||
|
shim rather than anything anyone wrote. */}
|
||||||
|
{shortCommit(run.origin_commit ?? "") || "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-muted-foreground text-sm tabular-nums">
|
||||||
|
{run.duration_ms ? dur(run.duration_ms) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-sm">
|
||||||
|
{ago(String(run.created_at ?? ""))}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-40 truncate text-muted-foreground text-sm">
|
||||||
|
{run.actor || "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The picked runs, one metric at a time. */
|
||||||
|
function Compare({
|
||||||
|
ids,
|
||||||
|
search,
|
||||||
|
update,
|
||||||
|
flow,
|
||||||
|
}: {
|
||||||
|
ids: string[]
|
||||||
|
search: RunsSearch
|
||||||
|
update: (next: Partial<RunsSearch>) => void
|
||||||
|
flow?: string
|
||||||
|
}) {
|
||||||
|
const names = useMetricNames(ids[0])
|
||||||
|
const metric =
|
||||||
|
search.metric && names.includes(search.metric)
|
||||||
|
? search.metric
|
||||||
|
: (names[0] ?? "")
|
||||||
|
const tooMany = ids.length > MAX_SERIES
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={cn(CARD, "flex flex-col gap-3")}>
|
||||||
|
<header className="flex flex-wrap items-center gap-3">
|
||||||
|
<h2 className="mr-auto font-medium text-sm">
|
||||||
|
Comparing {ids.length} run{ids.length === 1 ? "" : "s"}
|
||||||
|
</h2>
|
||||||
|
<MetricPicker
|
||||||
|
names={names}
|
||||||
|
value={metric}
|
||||||
|
onChange={(name) => update({ metric: name })}
|
||||||
|
/>
|
||||||
|
<OpenInDashboard flow={flow} ids={ids} />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={() => update({ compare: undefined })}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{tooMany && (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
A chart carries {MAX_SERIES} lines; the first {MAX_SERIES} of these
|
||||||
|
are drawn.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metric ? (
|
||||||
|
<RunMetricChart ids={ids.slice(0, MAX_SERIES)} metric={metric} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
These runs recorded no metric series to compare.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
|
||||||
|
import { OpenAPI, RunsService } from "@/client"
|
||||||
|
import { apiToken } from "@/lib/portal"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How deep one page of the run list is read.
|
||||||
|
*
|
||||||
|
* The endpoint caps a page at 500; asking for a hundred at a time keeps the
|
||||||
|
* first paint quick and leaves "Load more" something to do.
|
||||||
|
*/
|
||||||
|
export const PAGE = 100
|
||||||
|
|
||||||
|
/** How far the list will page before it stops offering to go deeper. */
|
||||||
|
export const LIST_CAP = 500
|
||||||
|
|
||||||
|
export type RunFilters = {
|
||||||
|
flow?: string
|
||||||
|
status?: string
|
||||||
|
group?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const runKeys = {
|
||||||
|
all: ["runs"] as const,
|
||||||
|
overview: ["runs", "overview"] as const,
|
||||||
|
list: (filters: RunFilters) => ["runs", "list", filters] as const,
|
||||||
|
detail: (id: string) => ["runs", "detail", id] as const,
|
||||||
|
metrics: (id: string, name: string) =>
|
||||||
|
["runs", "detail", id, "metrics", name] as const,
|
||||||
|
compare: (ids: string[], metric: string) =>
|
||||||
|
["runs", "compare", ids.join(","), metric] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The resting surface these screens are built from, as Health names it. */
|
||||||
|
export const CARD = "rounded-lg border border-border bg-card p-4 shadow-e1"
|
||||||
|
|
||||||
|
/** The statuses a run passes through, as the filter offers them. */
|
||||||
|
export const STATUSES = [
|
||||||
|
"queued",
|
||||||
|
"running",
|
||||||
|
"ok",
|
||||||
|
"error",
|
||||||
|
"cancelled",
|
||||||
|
"abandoned",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/** A run that has not settled is still worth re-reading. */
|
||||||
|
export const isLive = (status: string) =>
|
||||||
|
status === "queued" || status === "running"
|
||||||
|
|
||||||
|
export const runsInfiniteQueryOptions = (filters: RunFilters) => ({
|
||||||
|
queryKey: runKeys.list(filters),
|
||||||
|
queryFn: ({ pageParam }: { pageParam: number }) =>
|
||||||
|
RunsService.readRuns({ ...filters, limit: PAGE, offset: pageParam }),
|
||||||
|
initialPageParam: 0,
|
||||||
|
getNextPageParam: (last: unknown[], all: unknown[][]) => {
|
||||||
|
const read = all.reduce((total, page) => total + page.length, 0)
|
||||||
|
// A short page is the end of the history; the cap is the end of what this
|
||||||
|
// list will show of it.
|
||||||
|
return last.length < PAGE || read >= LIST_CAP ? undefined : read
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const runOverviewQueryOptions = () => ({
|
||||||
|
queryKey: runKeys.overview,
|
||||||
|
queryFn: () => RunsService.readOverview(),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const runQueryOptions = (id: string) => ({
|
||||||
|
queryKey: runKeys.detail(id),
|
||||||
|
queryFn: () => RunsService.readRun({ runId: id }),
|
||||||
|
// A finished run never changes again, so only a live one is polled. The
|
||||||
|
// socket's run_finished lands the last transition either way; this covers
|
||||||
|
// the metrics and node rows filling in while it runs.
|
||||||
|
refetchInterval: (query: { state: { data?: { status: string } } }) =>
|
||||||
|
query.state.data && isLive(query.state.data.status)
|
||||||
|
? 5_000
|
||||||
|
: (false as const),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** One run's series, or every one of them when `name` is empty. */
|
||||||
|
export const runMetricsQueryOptions = (id: string, name = "") => ({
|
||||||
|
queryKey: runKeys.metrics(id, name),
|
||||||
|
queryFn: () => RunsService.readMetrics({ runId: id, name }),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const compareQueryOptions = (
|
||||||
|
ids: string[],
|
||||||
|
metric: string,
|
||||||
|
refetchInterval?: number,
|
||||||
|
) => ({
|
||||||
|
queryKey: runKeys.compare(ids, metric),
|
||||||
|
queryFn: () => RunsService.compareMetric({ ids: ids.join(","), metric }),
|
||||||
|
enabled: ids.length > 0 && Boolean(metric),
|
||||||
|
...(refetchInterval ? { refetchInterval } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useCancelRun() {
|
||||||
|
const client = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (runId: string) => RunsService.cancelRun({ runId }),
|
||||||
|
onSuccess: () => client.invalidateQueries({ queryKey: runKeys.all }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save an artifact to disk.
|
||||||
|
*
|
||||||
|
* Not a plain link: `/artifacts/{digest}` takes a bearer token, which an
|
||||||
|
* anchor cannot carry. The bytes come through fetch and leave as an object
|
||||||
|
* URL — the same trip the browser would have made, with the header on it.
|
||||||
|
*/
|
||||||
|
export async function downloadArtifact(digest: string, name: string) {
|
||||||
|
const token = apiToken()
|
||||||
|
const answer = await fetch(`${OpenAPI.BASE}/api/v1/artifacts/${digest}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
})
|
||||||
|
if (!answer.ok) throw new Error(`Could not read ${name}`)
|
||||||
|
const url = URL.createObjectURL(await answer.blob())
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = url
|
||||||
|
link.download = name
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run id, short enough for a table cell. The tail is the random half. */
|
||||||
|
export const shortId = (id: string) => id.slice(-8)
|
||||||
|
|
||||||
|
/** A commit, at the length everyone reads one at. */
|
||||||
|
export const shortCommit = (commit: string) => commit.slice(0, 7)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameter keys that differ across these runs.
|
||||||
|
*
|
||||||
|
* What makes a sweep readable: fifty runs of one flow share everything but the
|
||||||
|
* two knobs that were swept, and those two are the only columns worth drawing.
|
||||||
|
*/
|
||||||
|
export function varyingKeys(runs: { params: Record<string, unknown> }[]) {
|
||||||
|
if (runs.length < 2) return []
|
||||||
|
const keys = new Set<string>()
|
||||||
|
for (const run of runs)
|
||||||
|
for (const key of Object.keys(run.params)) keys.add(key)
|
||||||
|
return [...keys].filter((key) => {
|
||||||
|
const first = JSON.stringify(runs[0].params[key])
|
||||||
|
return runs.some((run) => JSON.stringify(run.params[key]) !== first)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A parameter value, as narrow as it can be written. */
|
||||||
|
export function paramText(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "—"
|
||||||
|
if (typeof value === "number" || typeof value === "boolean")
|
||||||
|
return String(value)
|
||||||
|
if (typeof value === "string") return value
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A run's parameters on one line, for a table cell. */
|
||||||
|
export const paramsSummary = (params: Record<string, unknown>) =>
|
||||||
|
Object.entries(params)
|
||||||
|
.map(([key, value]) => `${key}=${paramText(value)}`)
|
||||||
|
.join(" ")
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Bell,
|
Bell,
|
||||||
|
FlaskConical,
|
||||||
Home,
|
Home,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -29,6 +30,9 @@ const baseItems: Item[] = [
|
|||||||
{ icon: Home, title: "Home", path: "/" },
|
{ icon: Home, title: "Home", path: "/" },
|
||||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||||
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
{ icon: LayoutDashboard, title: "Dashboards", path: "/dashboards" },
|
||||||
|
// What a batch flow leaves behind. Beside the flows rather than under Home:
|
||||||
|
// an experiment log is browsed, not glanced at.
|
||||||
|
{ icon: FlaskConical, title: "Runs", path: "/runs" },
|
||||||
// Both are engine-wide operator settings rather than personal ones, so they
|
// Both are engine-wide operator settings rather than personal ones, so they
|
||||||
// sit here and not among the per-user tabs under Settings.
|
// sit here and not among the per-user tabs under Settings.
|
||||||
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
|
{ icon: KeyRound, title: "Secrets", path: "/secrets" },
|
||||||
|
|||||||
@@ -25,8 +25,10 @@ import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
|
|||||||
import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
|
import { Route as LayoutModulesRouteImport } from './routes/_layout/modules'
|
||||||
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
import { Route as LayoutAlertsRouteImport } from './routes/_layout/alerts'
|
||||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||||
|
import { Route as LayoutRunsIndexRouteImport } from './routes/_layout/runs/index'
|
||||||
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
|
import { Route as LayoutFlowsIndexRouteImport } from './routes/_layout/flows/index'
|
||||||
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
import { Route as LayoutDashboardsIndexRouteImport } from './routes/_layout/dashboards/index'
|
||||||
|
import { Route as LayoutRunsIdRouteImport } from './routes/_layout/runs/$id'
|
||||||
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
import { Route as CanvasFlowsFlowNameRouteImport } from './routes/_canvas/flows/$flowName'
|
||||||
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
|
import { Route as CanvasDashboardsNameRouteImport } from './routes/_canvas/dashboards/$name'
|
||||||
|
|
||||||
@@ -108,6 +110,11 @@ const LayoutAdminRoute = LayoutAdminRouteImport.update({
|
|||||||
path: '/admin',
|
path: '/admin',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutRunsIndexRoute = LayoutRunsIndexRouteImport.update({
|
||||||
|
id: '/runs/',
|
||||||
|
path: '/runs/',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
|
const LayoutFlowsIndexRoute = LayoutFlowsIndexRouteImport.update({
|
||||||
id: '/flows/',
|
id: '/flows/',
|
||||||
path: '/flows/',
|
path: '/flows/',
|
||||||
@@ -118,6 +125,11 @@ const LayoutDashboardsIndexRoute = LayoutDashboardsIndexRouteImport.update({
|
|||||||
path: '/dashboards/',
|
path: '/dashboards/',
|
||||||
getParentRoute: () => LayoutRoute,
|
getParentRoute: () => LayoutRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const LayoutRunsIdRoute = LayoutRunsIdRouteImport.update({
|
||||||
|
id: '/runs/$id',
|
||||||
|
path: '/runs/$id',
|
||||||
|
getParentRoute: () => LayoutRoute,
|
||||||
|
} as any)
|
||||||
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
const CanvasFlowsFlowNameRoute = CanvasFlowsFlowNameRouteImport.update({
|
||||||
id: '/flows/$flowName',
|
id: '/flows/$flowName',
|
||||||
path: '/flows/$flowName',
|
path: '/flows/$flowName',
|
||||||
@@ -146,8 +158,10 @@ export interface FileRoutesByFullPath {
|
|||||||
'/panel/': typeof PanelIndexRoute
|
'/panel/': typeof PanelIndexRoute
|
||||||
'/dashboards/$name': typeof CanvasDashboardsNameRoute
|
'/dashboards/$name': typeof CanvasDashboardsNameRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
|
'/runs/$id': typeof LayoutRunsIdRoute
|
||||||
'/dashboards/': typeof LayoutDashboardsIndexRoute
|
'/dashboards/': typeof LayoutDashboardsIndexRoute
|
||||||
'/flows/': typeof LayoutFlowsIndexRoute
|
'/flows/': typeof LayoutFlowsIndexRoute
|
||||||
|
'/runs/': typeof LayoutRunsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof LayoutIndexRoute
|
'/': typeof LayoutIndexRoute
|
||||||
@@ -166,8 +180,10 @@ export interface FileRoutesByTo {
|
|||||||
'/panel': typeof PanelIndexRoute
|
'/panel': typeof PanelIndexRoute
|
||||||
'/dashboards/$name': typeof CanvasDashboardsNameRoute
|
'/dashboards/$name': typeof CanvasDashboardsNameRoute
|
||||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
|
'/runs/$id': typeof LayoutRunsIdRoute
|
||||||
'/dashboards': typeof LayoutDashboardsIndexRoute
|
'/dashboards': typeof LayoutDashboardsIndexRoute
|
||||||
'/flows': typeof LayoutFlowsIndexRoute
|
'/flows': typeof LayoutFlowsIndexRoute
|
||||||
|
'/runs': typeof LayoutRunsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -189,8 +205,10 @@ export interface FileRoutesById {
|
|||||||
'/panel/': typeof PanelIndexRoute
|
'/panel/': typeof PanelIndexRoute
|
||||||
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
|
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
|
||||||
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||||
|
'/_layout/runs/$id': typeof LayoutRunsIdRoute
|
||||||
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
|
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
|
||||||
'/_layout/flows/': typeof LayoutFlowsIndexRoute
|
'/_layout/flows/': typeof LayoutFlowsIndexRoute
|
||||||
|
'/_layout/runs/': typeof LayoutRunsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -211,8 +229,10 @@ export interface FileRouteTypes {
|
|||||||
| '/panel/'
|
| '/panel/'
|
||||||
| '/dashboards/$name'
|
| '/dashboards/$name'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
|
| '/runs/$id'
|
||||||
| '/dashboards/'
|
| '/dashboards/'
|
||||||
| '/flows/'
|
| '/flows/'
|
||||||
|
| '/runs/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/'
|
| '/'
|
||||||
@@ -231,8 +251,10 @@ export interface FileRouteTypes {
|
|||||||
| '/panel'
|
| '/panel'
|
||||||
| '/dashboards/$name'
|
| '/dashboards/$name'
|
||||||
| '/flows/$flowName'
|
| '/flows/$flowName'
|
||||||
|
| '/runs/$id'
|
||||||
| '/dashboards'
|
| '/dashboards'
|
||||||
| '/flows'
|
| '/flows'
|
||||||
|
| '/runs'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_canvas'
|
| '/_canvas'
|
||||||
@@ -253,8 +275,10 @@ export interface FileRouteTypes {
|
|||||||
| '/panel/'
|
| '/panel/'
|
||||||
| '/_canvas/dashboards/$name'
|
| '/_canvas/dashboards/$name'
|
||||||
| '/_canvas/flows/$flowName'
|
| '/_canvas/flows/$flowName'
|
||||||
|
| '/_layout/runs/$id'
|
||||||
| '/_layout/dashboards/'
|
| '/_layout/dashboards/'
|
||||||
| '/_layout/flows/'
|
| '/_layout/flows/'
|
||||||
|
| '/_layout/runs/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -384,6 +408,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutAdminRouteImport
|
preLoaderRoute: typeof LayoutAdminRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/runs/': {
|
||||||
|
id: '/_layout/runs/'
|
||||||
|
path: '/runs'
|
||||||
|
fullPath: '/runs/'
|
||||||
|
preLoaderRoute: typeof LayoutRunsIndexRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_layout/flows/': {
|
'/_layout/flows/': {
|
||||||
id: '/_layout/flows/'
|
id: '/_layout/flows/'
|
||||||
path: '/flows'
|
path: '/flows'
|
||||||
@@ -398,6 +429,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
|
preLoaderRoute: typeof LayoutDashboardsIndexRouteImport
|
||||||
parentRoute: typeof LayoutRoute
|
parentRoute: typeof LayoutRoute
|
||||||
}
|
}
|
||||||
|
'/_layout/runs/$id': {
|
||||||
|
id: '/_layout/runs/$id'
|
||||||
|
path: '/runs/$id'
|
||||||
|
fullPath: '/runs/$id'
|
||||||
|
preLoaderRoute: typeof LayoutRunsIdRouteImport
|
||||||
|
parentRoute: typeof LayoutRoute
|
||||||
|
}
|
||||||
'/_canvas/flows/$flowName': {
|
'/_canvas/flows/$flowName': {
|
||||||
id: '/_canvas/flows/$flowName'
|
id: '/_canvas/flows/$flowName'
|
||||||
path: '/flows/$flowName'
|
path: '/flows/$flowName'
|
||||||
@@ -435,8 +473,10 @@ interface LayoutRouteChildren {
|
|||||||
LayoutSecretsRoute: typeof LayoutSecretsRoute
|
LayoutSecretsRoute: typeof LayoutSecretsRoute
|
||||||
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
LayoutSettingsRoute: typeof LayoutSettingsRoute
|
||||||
LayoutIndexRoute: typeof LayoutIndexRoute
|
LayoutIndexRoute: typeof LayoutIndexRoute
|
||||||
|
LayoutRunsIdRoute: typeof LayoutRunsIdRoute
|
||||||
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
|
LayoutDashboardsIndexRoute: typeof LayoutDashboardsIndexRoute
|
||||||
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
|
LayoutFlowsIndexRoute: typeof LayoutFlowsIndexRoute
|
||||||
|
LayoutRunsIndexRoute: typeof LayoutRunsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutRouteChildren: LayoutRouteChildren = {
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
||||||
@@ -446,8 +486,10 @@ const LayoutRouteChildren: LayoutRouteChildren = {
|
|||||||
LayoutSecretsRoute: LayoutSecretsRoute,
|
LayoutSecretsRoute: LayoutSecretsRoute,
|
||||||
LayoutSettingsRoute: LayoutSettingsRoute,
|
LayoutSettingsRoute: LayoutSettingsRoute,
|
||||||
LayoutIndexRoute: LayoutIndexRoute,
|
LayoutIndexRoute: LayoutIndexRoute,
|
||||||
|
LayoutRunsIdRoute: LayoutRunsIdRoute,
|
||||||
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
|
LayoutDashboardsIndexRoute: LayoutDashboardsIndexRoute,
|
||||||
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
|
LayoutFlowsIndexRoute: LayoutFlowsIndexRoute,
|
||||||
|
LayoutRunsIndexRoute: LayoutRunsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayoutRouteWithChildren =
|
const LayoutRouteWithChildren =
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { RunDetail } from "@/components/Runs/RunDetail"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/runs/$id")({
|
||||||
|
component: Run,
|
||||||
|
head: ({ params }) => ({ meta: [{ title: `Run ${params.id} - Fluksio` }] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
function Run() {
|
||||||
|
const { id } = Route.useParams()
|
||||||
|
return <RunDetail id={id} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
import { RunsScreen, type RunsSearch } from "@/components/Runs/RunsScreen"
|
||||||
|
|
||||||
|
/** A string search param, or nothing when it is absent or empty. */
|
||||||
|
const text = (value: unknown) =>
|
||||||
|
typeof value === "string" && value ? value : undefined
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/_layout/runs/")({
|
||||||
|
component: Runs,
|
||||||
|
// The whole state of this screen — which flow, which sweep, which runs are
|
||||||
|
// being compared — is the address, so a comparison is something to send
|
||||||
|
// rather than something to describe.
|
||||||
|
validateSearch: (search: Record<string, unknown>): RunsSearch => ({
|
||||||
|
flow: text(search.flow),
|
||||||
|
status: text(search.status),
|
||||||
|
group: text(search.group),
|
||||||
|
compare: text(search.compare),
|
||||||
|
metric: text(search.metric),
|
||||||
|
}),
|
||||||
|
head: () => ({ meta: [{ title: "Runs - Fluksio" }] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
function Runs() {
|
||||||
|
const search = Route.useSearch()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RunsScreen
|
||||||
|
search={search}
|
||||||
|
update={(next) =>
|
||||||
|
navigate({ to: "/runs", search: { ...search, ...next } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user