@adcops/autocore-react 3.6.7 → 3.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,25 @@
1
- /* Runs excluded from cross-test aggregation are kept visible but dimmed and
2
- struck through, so an operator can see at a glance which rows don't feed
3
- the avg/min/max/stddev/count results for their sample group. */
1
+ /* Runs excluded from cross-test aggregation are kept visible but dimmed, so an
2
+ operator can see at a glance which rows don't feed the avg/min/max/stddev/
3
+ count results for their sample group. The row's Status column says the word
4
+ "Excluded" outright, and its Details dialog says what that means — the dim
5
+ is a scanning aid, never the only signal.
6
+
7
+ NO line-through. It used to be here and it read as "deleted": an excluded
8
+ run is intact, still downloadable, and one click from being counted again.
9
+
10
+ Nor is there an exception rule for the action buttons. There was one —
11
+ `.tis-row-excluded > td .p-button { opacity: 1; text-decoration: none }` —
12
+ and neither half of it could ever have worked, because both properties come
13
+ from the ancestor `td`:
14
+
15
+ • `opacity` on the td composites its whole subtree as one group. A
16
+ descendant's `opacity: 1` cannot escape an ancestor's 0.55.
17
+ • `text-decoration` propagates from an ancestor to its in-flow
18
+ descendants and cannot be cancelled by a descendant at all.
19
+
20
+ So anything that must be exempt has to be exempt at the td: put the effect
21
+ on the text cells, not on every cell. Kept as one rule here because 0.55
22
+ leaves the buttons legible and they are what the operator reaches for. */
4
23
  .tis-row-excluded > td {
5
24
  opacity: 0.55;
6
- text-decoration: line-through;
7
- }
8
- /* Keep the action buttons (Include / Download) legible and clickable. */
9
- .tis-row-excluded > td .p-button {
10
- opacity: 1;
11
- text-decoration: none;
12
25
  }
@@ -16,5 +16,11 @@ export interface ResultHistoryTableProps {
16
16
  */
17
17
  methodId?: string;
18
18
  }
19
+ type ChipSeverity = 'success' | 'info' | 'warning' | 'danger' | 'secondary' | undefined;
20
+ export interface StateChip {
21
+ label: string;
22
+ severity: ChipSeverity;
23
+ }
19
24
  export declare const ResultHistoryTable: React.FC<ResultHistoryTableProps>;
25
+ export {};
20
26
  //# sourceMappingURL=ResultHistoryTable.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ResultHistoryTable.d.ts","sourceRoot":"","sources":["../../../src/components/tis/ResultHistoryTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkD,MAAM,OAAO,CAAC;AAYvE,OAAO,0BAA0B,CAAC;AAElC,MAAM,WAAW,uBAAuB;IACpC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAkED,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CA+pBhE,CAAC"}
1
+ {"version":3,"file":"ResultHistoryTable.d.ts","sourceRoot":"","sources":["../../../src/components/tis/ResultHistoryTable.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAkD,MAAM,OAAO,CAAC;AAavE,OAAO,0BAA0B,CAAC;AAElC,MAAM,WAAW,uBAAuB;IACpC;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AA8DD,KAAK,YAAY,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,WAAW,GAAG,SAAS,CAAC;AACxF,MAAM,WAAW,SAAS;IAAG,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,YAAY,CAAA;CAAE;AAkNpE,eAAO,MAAM,kBAAkB,EAAE,KAAK,CAAC,EAAE,CAAC,uBAAuB,CAyoBhE,CAAC"}
@@ -1 +1 @@
1
- import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useRef}from"react";import{DataTable}from"primereact/datatable";import{Column}from"primereact/column";import{Button}from"primereact/button";import{Badge}from"primereact/badge";import{Menu}from"primereact/menu";import{Toast}from"primereact/toast";import{confirmDialog}from"primereact/confirmdialog";import{EventEmitterContext}from"../../core/EventEmitterContext";import{MessageType}from"../../hub/CommandMessage";import{useTis}from"./TisProvider";import{SampleSummaryPanel}from"./SampleSummaryPanel";import"./ResultHistoryTable.css";const downloadCsvBlob=(e,t)=>{const o=new Blob([t],{type:"text/csv;charset=utf-8;"}),i=URL.createObjectURL(o),s=document.createElement("a");s.href=i,s.download=e,document.body.appendChild(s),s.click(),s.remove(),URL.revokeObjectURL(i)},downloadViaUrl=(e,t)=>{const o=document.createElement("a");o.href=e,o.download=t,document.body.appendChild(o),o.click(),o.remove()},LIFECYCLE_DONE={complete:{summary:"Test closed",detail:e=>`${e} is now Completed. It can still be reopened.`},abandon:{summary:"Test abandoned",detail:e=>`${e} is out of the history and out of the sample averages. Its data is still on disk — use Show abandoned to see it.`},reopen:{summary:"Test reopened",detail:e=>`${e} is open again — new cycles are added to it.`}};export const ResultHistoryTable=e=>{const t=useTis(),o=e.projectId??t.selection.projectId,i=e.methodId,[s,a]=useState([]),[r,n]=useState(!1),[l,d]=useState(null),[c,p]=useState(null),[u,m]=useState(null),f=useRef(null),[h,_]=useState(null),b=useRef(0),[y,g]=useState(null),[w,x]=useState(!1),v=useRef(null),{invoke:C}=useContext(EventEmitterContext),j=useRef(0),T=async()=>{if(!o)return void a([]);const e=++j.current;n(!0);try{const t={project_id:o};i&&(t.method_id=i);const s=await C("tis.list_tests",MessageType.Request,t);if(e!==j.current)return;s.success&&s.data&&s.data.tests&&a(s.data.tests)}catch(e){}e===j.current&&n(!1)};useEffect(()=>{T()},[o,i,t.state.activeRunId,t.state.active]),useEffect(()=>{h&&f.current?.show({currentTarget:h.anchor})},[h]);const $=async(e,t)=>{const s=e?.run_id,a=e?.method_id??i;if(!s||!a||!o)return;const r="report"===t?"tis.export_test_csv":"tis.export_test_data_csv",n="report"===t?"test report":"test data";d({runId:s,kind:t});try{const e=await C(r,MessageType.Request,{project_id:o,method_id:a,run_id:s});if(!e?.success)return void alert(`Failed to build ${n} for ${s}`+(e?.error_message?`: ${e.error_message}`:""));const i="string"==typeof e.data?.filename&&e.data.filename?e.data.filename:`${o}_${a}_${s}_${t}.csv`;if("report"===t){const t="string"==typeof e.data?.csv?e.data.csv:"";if(!t)return void alert(`${n} for ${s} is empty.`);downloadCsvBlob(i,t)}else{const t="string"==typeof e.data?.download_url?e.data.download_url:"";if(!t)return void alert(`Server did not return a download URL for ${n}.`);downloadViaUrl(t,i)}}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{d(null)}},R=async(e,t)=>{const s=e?.run_id,a=e?.method_id??i;if(!s||!a||!o)return;const r="complete"===t?"tis.finish_test":"abandon"===t?"tis.reset_test":"tis.resume_test",n=e?.sample_id||s;g(s);try{const e=await C(r,MessageType.Request,{project_id:o,method_id:a,run_id:s});if(!e?.success)return void alert(`Could not ${t} ${s}`+(e?.error_message?`:\n\n${e.error_message}`:""));await T(),v.current?.show({severity:"abandon"===t?"warn":"success",summary:LIFECYCLE_DONE[t].summary,detail:LIFECYCLE_DONE[t].detail(n),life:4e3})}catch(e){alert(`${t} failed: ${e instanceof Error?e.message:String(e)}`)}finally{g(null)}},S=async e=>{if(!o)return;const t="report"===e?"tis.export_project_csv":"tis.export_project_zip",i="report"===e?"project report":"project archive";p(e);try{const s=await C(t,MessageType.Request,{project_id:o});if(!s?.success)return void alert(`Failed to build ${i}`+(s?.error_message?`: ${s.error_message}`:""));const a="string"==typeof s.data?.download_url?s.data.download_url:"";if(!a)return void alert(`Server did not return a download URL for ${i}.`);const r="report"===e?`${o}_project_report.csv`:`${o}_project_archive.zip`,n="string"==typeof s.data?.filename&&s.data.filename?s.data.filename:r;downloadViaUrl(a,n)}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{p(null)}},k=e=>{const t="string"==typeof e?.sample_id?e.sample_id:"";if(t)return t;const o=e?.config;return o&&"object"==typeof o&&"string"==typeof o.sample_id?o.sample_id:""},I=s.filter(e=>!0===e?.finalized).length,E=w?s:s.filter(e=>!0!==e?.finalized);return _jsxs("div",{style:{width:"100%",maxWidth:"100%",overflow:"hidden",boxSizing:"border-box"},children:[_jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"1rem",gap:"0.5rem",flexWrap:"wrap"},children:[_jsx("h3",{style:{margin:0},children:o?`Test History: ${o}${i?` / ${i}`:""}`:"Test History (no project selected)"}),_jsxs("div",{style:{display:"flex",gap:"0.4rem",alignItems:"center"},children:[I>0&&_jsx(Button,{icon:w?"pi pi-eye-slash":"pi pi-eye",label:w?"Hide abandoned":`Show abandoned (${I})`,size:"small",outlined:!0,severity:"secondary",onClick:()=>x(e=>!e)}),_jsx(Button,{icon:"report"===c?"pi pi-spin pi-spinner":"pi pi-file",label:"Download Report",size:"small",outlined:!0,disabled:!o||null!==c,onClick:()=>S("report"),tooltip:"Download a CSV report of every test in this project",tooltipOptions:{position:"bottom"}}),_jsx(Button,{icon:"archive"===c?"pi pi-spin pi-spinner":"pi pi-box",label:"Download Archive",size:"small",outlined:!0,disabled:!o||null!==c,onClick:()=>S("archive"),tooltip:"Download a ZIP of the entire project directory (all tests, raw data, configs)",tooltipOptions:{position:"bottom"}}),_jsx(Button,{icon:"pi pi-refresh",label:"Refresh",size:"small",onClick:T,disabled:r})]})]}),_jsx(SampleSummaryPanel,{projectId:o,methodId:i}),_jsxs(DataTable,{value:E,loading:r,paginator:!0,rows:10,emptyMessage:"No tests found.",scrollable:!0,scrollHeight:"flex",tableStyle:{minWidth:0},style:{width:"100%"},rowClassName:e=>!0===e?.excluded?"tis-row-excluded":"",selectionMode:"single",onSelectionChange:e=>{const o=e.value;o?.run_id&&t.setSelection({projectId:o.project_id??null,methodId:o.method_id??null,runId:o.run_id})},children:[_jsx(Column,{header:"Sample ID",sortable:!0,body:k,sortFunction:e=>{const t=[...e.data];return t.sort((t,o)=>k(t).localeCompare(k(o))*(e.order??1)),t},style:{minWidth:"8rem"}}),_jsx(Column,{field:"start_time",header:"Date/Time",sortable:!0,body:e=>{return(t=e.start_time)?new Date(t).toLocaleString():"";var t},style:{minWidth:"12rem"}}),_jsx(Column,{field:"method_id",header:"Test Method",sortable:!0,style:{minWidth:"10rem"}}),_jsx(Column,{header:"Status",field:"status",sortable:!0,body:e=>{const t=(e=>{if(!0===e?.finalized)return{label:"Abandoned",severity:"danger"};const t="string"==typeof e?.status?e.status:"completed";if("completed"===t&&!0===e?.auto_closed)return{label:"Interrupted",severity:"warning"};switch(t){case"in_progress":return{label:"In Progress",severity:"info"};case"paused":return{label:"Paused",severity:"warning"};case"completed":return{label:"Completed",severity:"success"};default:return{label:t,severity:void 0}}})(e);return _jsx(Badge,{value:t.label,severity:t.severity})},style:{minWidth:"6rem"}}),_jsx(Column,{header:"Incl.",align:"center",alignHeader:"center",style:{width:"4.5rem"},body:e=>{const t=!0===e?.excluded,s=u===e.run_id;return _jsx(Button,{icon:s?"pi pi-spin pi-spinner":t?"pi pi-eye-slash":"pi pi-check",rounded:!0,text:!0,size:"small",severity:t?"warning":"success",disabled:null!==u,onClick:()=>(async e=>{const t=e?.run_id,s=e?.method_id??i;if(!t||!s||!o)return;const a=!0!==e?.excluded;m(t);try{const e=await C("tis.set_test_excluded",MessageType.Request,{project_id:o,method_id:s,run_id:t,excluded:a});if(!e?.success)return void alert(`Failed to ${a?"exclude":"include"} ${t}`+(e?.error_message?`: ${e.error_message}`:""));await T()}catch(e){alert(`Exclude toggle failed: ${e instanceof Error?e.message:String(e)}`)}finally{m(null)}})(e),"aria-label":t?"Excluded — click to include":"Included — click to exclude",tooltip:t?"Excluded from cross-test results — click to include":"Included in cross-test results — click to exclude",tooltipOptions:{position:"left"}})}}),_jsx(Column,{header:"Download",style:{width:"14rem"},body:e=>{const t=l?.runId===e.run_id&&"data"===l?.kind,o=l?.runId===e.run_id&&"report"===l?.kind,i=null!==l;return _jsxs("div",{style:{display:"flex",gap:"0.4rem"},children:[_jsx(Button,{icon:t?"pi pi-spin pi-spinner":"pi pi-download",label:"Raw",size:"small",outlined:!0,disabled:i,onClick:()=>$(e,"data"),tooltip:"Download raw + filtered trace data as one CSV",tooltipOptions:{position:"left"}}),_jsx(Button,{icon:o?"pi pi-spin pi-spinner":"pi pi-file",label:"Results",size:"small",outlined:!0,disabled:i,onClick:()=>$(e,"report"),tooltip:"Download a CSV report (metadata + cycles + results) for this test",tooltipOptions:{position:"left"}})]})}}),_jsx(Column,{header:"",style:{width:"3rem"},body:e=>_jsx(Button,{icon:y===e.run_id?"pi pi-spin pi-spinner":"pi pi-ellipsis-v",text:!0,rounded:!0,size:"small",disabled:null!==y,"aria-label":"Test actions",tooltip:"Complete / abandon / reopen this test",tooltipOptions:{position:"left"},onClick:t=>{b.current+=1,_({row:e,anchor:t.currentTarget,seq:b.current})}})})]}),_jsx(Menu,{model:h?(e=>{const t=e?.status,o=!0===e?.finalized,i="in_progress"===t||"paused"===t,s="completed"===t&&!0===e?.auto_closed&&!o,a=[];return i?(a.push({label:"Complete Test",icon:"pi pi-check-circle",command:()=>R(e,"complete")}),a.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>R(e,"abandon")})})):s?(a.push({label:"Close Test",icon:"pi pi-check-circle",command:()=>confirmDialog({header:"Close Interrupted Test",message:`Close ${e?.sample_id||e?.run_id}? It was closed automatically when the software restarted mid-test. Closing it marks it Completed and keeps the recorded end time. Recorded data is not changed, and the test can still be reopened later.`,icon:"pi pi-check-circle",acceptLabel:"Close Test",accept:()=>R(e,"complete")})}),a.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>R(e,"reopen")})}),a.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>R(e,"abandon")})})):"completed"!==t||o?a.push({label:o?"Abandoned — cannot reopen":"No actions available",icon:"pi pi-info-circle",disabled:!0}):a.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>R(e,"reopen")})}),a})(h.row):[],popup:!0,ref:f}),_jsx(Toast,{ref:v,position:"top-right"})]})};
1
+ import{jsx as _jsx,Fragment as _Fragment,jsxs as _jsxs}from"react/jsx-runtime";import React,{useState,useEffect,useContext,useRef}from"react";import{DataTable}from"primereact/datatable";import{Column}from"primereact/column";import{Button}from"primereact/button";import{Badge}from"primereact/badge";import{Menu}from"primereact/menu";import{Toast}from"primereact/toast";import{Dialog}from"primereact/dialog";import{confirmDialog}from"primereact/confirmdialog";import{EventEmitterContext}from"../../core/EventEmitterContext";import{MessageType}from"../../hub/CommandMessage";import{useTis}from"./TisProvider";import{SampleSummaryPanel}from"./SampleSummaryPanel";import"./ResultHistoryTable.css";const downloadCsvBlob=(e,t)=>{const a=new Blob([t],{type:"text/csv;charset=utf-8;"}),i=URL.createObjectURL(a),s=document.createElement("a");s.href=i,s.download=e,document.body.appendChild(s),s.click(),s.remove(),URL.revokeObjectURL(i)},downloadViaUrl=(e,t)=>{const a=document.createElement("a");a.href=e,a.download=t,document.body.appendChild(a),a.click(),a.remove()},LIFECYCLE_DONE={complete:{summary:"Test closed",detail:e=>`${e} is now Completed. It can still be reopened.`},abandon:{summary:"Test abandoned",detail:e=>`${e} is out of the history and out of the sample averages. Its data is still on disk — use Show abandoned to see it.`},reopen:{summary:"Test reopened",detail:e=>`${e} is open again — new cycles are added to it.`}},stateOf=e=>{const t="string"==typeof e?.status?e.status:"completed",a=!0===e?.finalized,i="completed"===t&&!0===e?.auto_closed,s="in_progress"===t||"paused"===t,o="completed"===t&&!i&&!a;return{lifecycle:a?{label:"Abandoned",severity:"danger"}:"in_progress"===t?{label:"In Progress",severity:"info"}:"paused"===t?{label:"Paused",severity:"warning"}:i?{label:"Interrupted",severity:"warning"}:"completed"===t?{label:"Completed",severity:"success"}:{label:t,severity:void 0},excluded:!0===e?.excluded?{label:"Excluded",severity:"secondary"}:null,live:s,abandoned:a,interrupted:i,settled:o}},stateChipsOf=e=>{const{lifecycle:t,excluded:a}=stateOf(e);return a?[t,a]:[t]},statusOf=e=>{const{lifecycle:t,excluded:a,settled:i}=stateOf(e);return a&&i?a:t},sampleIdOf=e=>{const t="string"==typeof e?.sample_id?e.sample_id:"";if(t)return t;const a=e?.config;return a&&"object"==typeof a&&"string"==typeof a.sample_id?a.sample_id:""},formatDate=e=>e?new Date(e).toLocaleString():"",DetailRow=({label:e,value:t,mono:a})=>null==t||""===t?null:_jsxs(_Fragment,{children:[_jsx("dt",{style:{color:"var(--text-color-secondary)",fontSize:"0.9em"},children:e}),_jsx("dd",{style:{margin:0,fontFamily:a?"monospace":void 0},children:t})]}),DetailGrid=({title:e,children:t})=>_jsxs("section",{style:{marginTop:"1rem"},children:[_jsx("h4",{style:{margin:"0 0 0.4rem",fontSize:"0.95em"},children:e}),_jsx("dl",{style:{display:"grid",gridTemplateColumns:"minmax(9rem, max-content) 1fr",gap:"0.3rem 1rem",margin:0},children:t})]}),configValueText=e=>null==e?"—":"number"==typeof e?Number.isFinite(e)?String(Number.parseFloat(e.toPrecision(6))):String(e):"boolean"==typeof e?e?"Yes":"No":"object"==typeof e?JSON.stringify(e):String(e),TestDetails=({row:e})=>{const t=stateChipsOf(e),{abandoned:a}=stateOf(e),i=e?.config&&"object"==typeof e.config?e.config:{},s=Object.entries(i).filter(([e])=>"sample_id"!==e);return _jsxs("div",{children:[_jsx("div",{style:{display:"flex",gap:"0.4rem",flexWrap:"wrap"},children:t.map(e=>_jsx(Badge,{value:e.label,severity:e.severity},e.label))}),_jsxs(DetailGrid,{title:"Identity",children:[_jsx(DetailRow,{label:"Sample ID",value:sampleIdOf(e)}),_jsx(DetailRow,{label:"Test Method",value:e?.method_id}),_jsx(DetailRow,{label:"Project",value:e?.project_id}),_jsx(DetailRow,{label:"Test ID",value:e?.run_id,mono:!0})]}),_jsxs(DetailGrid,{title:"Timing",children:[_jsx(DetailRow,{label:"Started",value:formatDate(e?.start_time)}),_jsx(DetailRow,{label:"Ended",value:formatDate(e?.end_time)}),_jsx(DetailRow,{label:"Paused",value:formatDate(e?.paused_time)}),_jsx(DetailRow,{label:"Resumed",value:formatDate(e?.resumed_time)})]}),(e?.close_reason||!0===e?.excluded||a)&&_jsxs(DetailGrid,{title:"State",children:[_jsx(DetailRow,{label:"Interrupted",value:e?.close_reason}),_jsx(DetailRow,{label:"Excluded",value:!0===e?.excluded?`Not counted in the ${sampleIdOf(e)||"sample"} results`+(e?.excluded_at?` · since ${formatDate(e.excluded_at)}`:"")+". Use the Incl. column to put it back.":void 0}),_jsx(DetailRow,{label:"Abandoned",value:a?"Kept on disk, out of the results, and cannot be reopened.":void 0})]}),s.length>0&&_jsx(DetailGrid,{title:"Configuration as recorded",children:s.map(([e,t])=>_jsx(DetailRow,{label:e,value:configValueText(t)},e))})]})};export const ResultHistoryTable=e=>{const t=useTis(),a=e.projectId??t.selection.projectId,i=e.methodId,[s,o]=useState([]),[r,l]=useState(!1),[n,d]=useState(null),[c,p]=useState(null),[m,u]=useState(null),f=useRef(null),[_,b]=useState(null),h=useRef(0),[y,g]=useState(null),[x,j]=useState(!1),[v,w]=useState(null),C=useRef(null),{invoke:D}=useContext(EventEmitterContext),R=useRef(0),T=async()=>{if(!a)return void o([]);const e=++R.current;l(!0);try{const t={project_id:a};i&&(t.method_id=i);const s=await D("tis.list_tests",MessageType.Request,t);if(e!==R.current)return;s.success&&s.data&&s.data.tests&&o(s.data.tests)}catch(e){}e===R.current&&l(!1)};useEffect(()=>{T()},[a,i,t.state.activeRunId,t.state.active]),useEffect(()=>{_&&f.current?.show({currentTarget:_.anchor})},[_]);const S=async(e,t)=>{const s=e?.run_id,o=e?.method_id??i;if(!s||!o||!a)return;const r="report"===t?"tis.export_test_csv":"tis.export_test_data_csv",l="report"===t?"test report":"test data";d({runId:s,kind:t});try{const e=await D(r,MessageType.Request,{project_id:a,method_id:o,run_id:s});if(!e?.success)return void alert(`Failed to build ${l} for ${s}`+(e?.error_message?`: ${e.error_message}`:""));const i="string"==typeof e.data?.filename&&e.data.filename?e.data.filename:`${a}_${o}_${s}_${t}.csv`;if("report"===t){const t="string"==typeof e.data?.csv?e.data.csv:"";if(!t)return void alert(`${l} for ${s} is empty.`);downloadCsvBlob(i,t)}else{const t="string"==typeof e.data?.download_url?e.data.download_url:"";if(!t)return void alert(`Server did not return a download URL for ${l}.`);downloadViaUrl(t,i)}}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{d(null)}},$=async(e,t)=>{const s=e?.run_id,o=e?.method_id??i;if(!s||!o||!a)return;const r="complete"===t?"tis.finish_test":"abandon"===t?"tis.reset_test":"tis.resume_test",l=e?.sample_id||s;g(s);try{const e=await D(r,MessageType.Request,{project_id:a,method_id:o,run_id:s});if(!e?.success)return void alert(`Could not ${t} ${s}`+(e?.error_message?`:\n\n${e.error_message}`:""));await T(),C.current?.show({severity:"abandon"===t?"warn":"success",summary:LIFECYCLE_DONE[t].summary,detail:LIFECYCLE_DONE[t].detail(l),life:4e3})}catch(e){alert(`${t} failed: ${e instanceof Error?e.message:String(e)}`)}finally{g(null)}},I=async e=>{if(!a)return;const t="report"===e?"tis.export_project_csv":"tis.export_project_zip",i="report"===e?"project report":"project archive";p(e);try{const s=await D(t,MessageType.Request,{project_id:a});if(!s?.success)return void alert(`Failed to build ${i}`+(s?.error_message?`: ${s.error_message}`:""));const o="string"==typeof s.data?.download_url?s.data.download_url:"";if(!o)return void alert(`Server did not return a download URL for ${i}.`);const r="report"===e?`${a}_project_report.csv`:`${a}_project_archive.zip`,l="string"==typeof s.data?.filename&&s.data.filename?s.data.filename:r;downloadViaUrl(o,l)}catch(e){alert(`Download failed: ${e instanceof Error?e.message:String(e)}`)}finally{p(null)}},k=s.filter(e=>!0===e?.finalized).length,E=x?s:s.filter(e=>!0!==e?.finalized);return _jsxs("div",{style:{width:"100%",maxWidth:"100%",overflow:"hidden",boxSizing:"border-box"},children:[_jsxs("div",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:"1rem",gap:"0.5rem",flexWrap:"wrap"},children:[_jsx("h3",{style:{margin:0},children:a?`Test History: ${a}${i?` / ${i}`:""}`:"Test History (no project selected)"}),_jsxs("div",{style:{display:"flex",gap:"0.4rem",alignItems:"center"},children:[k>0&&_jsx(Button,{icon:x?"pi pi-eye-slash":"pi pi-eye",label:x?"Hide abandoned":`Show abandoned (${k})`,size:"small",outlined:!0,severity:"secondary",onClick:()=>j(e=>!e)}),_jsx(Button,{icon:"report"===c?"pi pi-spin pi-spinner":"pi pi-file",label:"Download Report",size:"small",outlined:!0,disabled:!a||null!==c,onClick:()=>I("report")}),_jsx(Button,{icon:"archive"===c?"pi pi-spin pi-spinner":"pi pi-box",label:"Download Archive",size:"small",outlined:!0,disabled:!a||null!==c,onClick:()=>I("archive")}),_jsx(Button,{icon:"pi pi-refresh",label:"Refresh",size:"small",onClick:T,disabled:r})]})]}),_jsx(SampleSummaryPanel,{projectId:a,methodId:i}),_jsxs(DataTable,{value:E,loading:r,paginator:!0,rows:10,emptyMessage:"No tests found.",scrollable:!0,scrollHeight:"flex",tableStyle:{minWidth:0},style:{width:"100%"},rowClassName:e=>!0===e?.excluded?"tis-row-excluded":"",selectionMode:"single",onSelectionChange:e=>{const a=e.value;a?.run_id&&t.setSelection({projectId:a.project_id??null,methodId:a.method_id??null,runId:a.run_id})},children:[_jsx(Column,{header:"Sample ID",sortable:!0,body:sampleIdOf,sortFunction:e=>{const t=[...e.data];return t.sort((t,a)=>sampleIdOf(t).localeCompare(sampleIdOf(a))*(e.order??1)),t},style:{minWidth:"8rem"}}),_jsx(Column,{field:"start_time",header:"Date/Time",sortable:!0,body:e=>formatDate(e.start_time),style:{minWidth:"12rem"}}),_jsx(Column,{field:"method_id",header:"Test Method",sortable:!0,style:{minWidth:"10rem"}}),_jsx(Column,{header:"Status",field:"status",sortable:!0,body:e=>{const t=statusOf(e);return _jsx(Badge,{value:t.label,severity:t.severity})},style:{minWidth:"6rem"}}),_jsx(Column,{header:"Incl.",align:"center",alignHeader:"center",style:{width:"4.5rem"},body:e=>{const t=!0===e?.excluded,s=m===e.run_id;return _jsx(Button,{icon:s?"pi pi-spin pi-spinner":t?"pi pi-eye-slash":"pi pi-check",rounded:!0,text:!0,size:"small",severity:t?"warning":"success",disabled:null!==m,onClick:()=>(async e=>{const t=e?.run_id,s=e?.method_id??i;if(!t||!s||!a)return;const o=!0!==e?.excluded;u(t);try{const e=await D("tis.set_test_excluded",MessageType.Request,{project_id:a,method_id:s,run_id:t,excluded:o});if(!e?.success)return void alert(`Failed to ${o?"exclude":"include"} ${t}`+(e?.error_message?`: ${e.error_message}`:""));await T()}catch(e){alert(`Exclude toggle failed: ${e instanceof Error?e.message:String(e)}`)}finally{u(null)}})(e),"aria-label":t?"Excluded — click to include":"Included — click to exclude"})}}),_jsx(Column,{header:"Download",style:{width:"14rem"},body:e=>{const t=n?.runId===e.run_id&&"data"===n?.kind,a=n?.runId===e.run_id&&"report"===n?.kind,i=null!==n;return _jsxs("div",{style:{display:"flex",gap:"0.4rem"},children:[_jsx(Button,{icon:t?"pi pi-spin pi-spinner":"pi pi-download",label:"Raw",size:"small",outlined:!0,disabled:i,onClick:()=>S(e,"data")}),_jsx(Button,{icon:a?"pi pi-spin pi-spinner":"pi pi-file",label:"Results",size:"small",outlined:!0,disabled:i,onClick:()=>S(e,"report")})]})}}),_jsx(Column,{header:"",style:{width:"3rem"},body:e=>_jsx(Button,{icon:y===e.run_id?"pi pi-spin pi-spinner":"pi pi-ellipsis-v",text:!0,rounded:!0,size:"small",disabled:null!==y,"aria-label":"Test actions",onClick:t=>{h.current+=1,b({row:e,anchor:t.currentTarget,seq:h.current})}})})]}),_jsx(Menu,{model:_?(e=>{const t=e?.status,a=!0===e?.finalized,i="in_progress"===t||"paused"===t,s="completed"===t&&!0===e?.auto_closed&&!a,o=[{label:"Details",icon:"pi pi-info-circle",command:()=>w(e)},{separator:!0}];return i?(o.push({label:"Complete Test",icon:"pi pi-check-circle",command:()=>$(e,"complete")}),o.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>$(e,"abandon")})})):s?(o.push({label:"Close Test",icon:"pi pi-check-circle",command:()=>confirmDialog({header:"Close Interrupted Test",message:`Close ${e?.sample_id||e?.run_id}? It was closed automatically when the software restarted mid-test. Closing it marks it Completed and keeps the recorded end time. Recorded data is not changed, and the test can still be reopened later.`,icon:"pi pi-check-circle",acceptLabel:"Close Test",accept:()=>$(e,"complete")})}),o.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>$(e,"reopen")})}),o.push({label:"Abandon Test",icon:"pi pi-ban",command:()=>confirmDialog({header:"Abandon Test",message:`Mark ${e?.sample_id||e?.run_id} as abandoned? Recorded data is kept, but the test can no longer be reopened.`,icon:"pi pi-exclamation-triangle",acceptClassName:"p-button-danger",acceptLabel:"Abandon",accept:()=>$(e,"abandon")})})):"completed"!==t||a?a&&o.push({label:"Abandoned — cannot reopen",icon:"pi pi-ban",disabled:!0}):o.push({label:"Reopen Test",icon:"pi pi-replay",command:()=>confirmDialog({header:"Reopen Test",message:`Reopen ${e?.sample_id||e?.run_id} so new cycles are added to it? Cycle numbering continues from where it stopped. This changes where data is recorded — it does not start the machine.`,icon:"pi pi-replay",acceptLabel:"Reopen",accept:()=>$(e,"reopen")})}),o})(_.row):[],popup:!0,ref:f}),_jsx(Toast,{ref:C,position:"top-right"}),_jsx(Dialog,{header:"Test Details",visible:null!==v,onHide:()=>w(null),style:{width:"min(44rem, 94vw)"},dismissableMask:!0,children:v&&_jsx(TestDetails,{row:v})})]})};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adcops/autocore-react",
3
- "version": "3.6.7",
3
+ "version": "3.6.8",
4
4
  "description": "A React component library for industrial user interfaces.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -1,12 +1,25 @@
1
- /* Runs excluded from cross-test aggregation are kept visible but dimmed and
2
- struck through, so an operator can see at a glance which rows don't feed
3
- the avg/min/max/stddev/count results for their sample group. */
1
+ /* Runs excluded from cross-test aggregation are kept visible but dimmed, so an
2
+ operator can see at a glance which rows don't feed the avg/min/max/stddev/
3
+ count results for their sample group. The row's Status column says the word
4
+ "Excluded" outright, and its Details dialog says what that means — the dim
5
+ is a scanning aid, never the only signal.
6
+
7
+ NO line-through. It used to be here and it read as "deleted": an excluded
8
+ run is intact, still downloadable, and one click from being counted again.
9
+
10
+ Nor is there an exception rule for the action buttons. There was one —
11
+ `.tis-row-excluded > td .p-button { opacity: 1; text-decoration: none }` —
12
+ and neither half of it could ever have worked, because both properties come
13
+ from the ancestor `td`:
14
+
15
+ • `opacity` on the td composites its whole subtree as one group. A
16
+ descendant's `opacity: 1` cannot escape an ancestor's 0.55.
17
+ • `text-decoration` propagates from an ancestor to its in-flow
18
+ descendants and cannot be cancelled by a descendant at all.
19
+
20
+ So anything that must be exempt has to be exempt at the td: put the effect
21
+ on the text cells, not on every cell. Kept as one rule here because 0.55
22
+ leaves the buttons legible and they are what the operator reaches for. */
4
23
  .tis-row-excluded > td {
5
24
  opacity: 0.55;
6
- text-decoration: line-through;
7
- }
8
- /* Keep the action buttons (Include / Download) legible and clickable. */
9
- .tis-row-excluded > td .p-button {
10
- opacity: 1;
11
- text-decoration: none;
12
25
  }
@@ -5,6 +5,7 @@ import { Button } from 'primereact/button';
5
5
  import { Badge } from 'primereact/badge';
6
6
  import { Menu } from 'primereact/menu';
7
7
  import { Toast } from 'primereact/toast';
8
+ import { Dialog } from 'primereact/dialog';
8
9
  import { confirmDialog } from 'primereact/confirmdialog';
9
10
  import { EventEmitterContext } from '../../core/EventEmitterContext';
10
11
  import { MessageType } from '../../hub/CommandMessage';
@@ -89,10 +90,217 @@ const LIFECYCLE_DONE: Record<
89
90
  },
90
91
  };
91
92
 
93
+ type ChipSeverity = 'success' | 'info' | 'warning' | 'danger' | 'secondary' | undefined;
94
+ export interface StateChip { label: string; severity: ChipSeverity }
95
+
96
+ /**
97
+ * A run's state on its two independent axes.
98
+ *
99
+ * `lifecycle` is what happened to the run — written by the TIS servelet
100
+ * (start_test → in_progress, pause_test → paused, finish_test → completed,
101
+ * reset_test → finalized, and `auto_closed` by the startup sweep). Runs
102
+ * recorded before those fields existed carry neither; treat them as completed,
103
+ * since they are long since done.
104
+ *
105
+ * `excluded` is a separate judgement about the DATA, set only by the Incl.
106
+ * toggle, and it does not replace the lifecycle — a run can be Completed and
107
+ * Excluded at once. Which of the two the narrow Status column shows is
108
+ * [`statusOf`]'s decision; the Details dialog shows both.
109
+ */
110
+ const stateOf = (rowData: any) => {
111
+ const status = typeof rowData?.status === 'string' ? rowData.status : 'completed';
112
+ const abandoned = rowData?.finalized === true;
113
+ const interrupted = status === 'completed' && rowData?.auto_closed === true;
114
+ // Open right now, so nothing may hide it.
115
+ const live = status === 'in_progress' || status === 'paused';
116
+ // Finished, and nobody is waiting on the operator for anything. The only
117
+ // lifecycle state that has nothing left to say.
118
+ const settled = status === 'completed' && !interrupted && !abandoned;
119
+
120
+ const lifecycle: StateChip =
121
+ abandoned ? { label: 'Abandoned', severity: 'danger' }
122
+ : status === 'in_progress' ? { label: 'In Progress', severity: 'info' }
123
+ : status === 'paused' ? { label: 'Paused', severity: 'warning' }
124
+ // Closed by the startup sweep because the previous process stopped
125
+ // mid-test. It IS closed, but the operator never finished it, so
126
+ // "Completed" would overstate what happened. Still reopenable —
127
+ // `finalized` is deliberately not set by that sweep.
128
+ : interrupted ? { label: 'Interrupted', severity: 'warning' }
129
+ : status === 'completed' ? { label: 'Completed', severity: 'success' }
130
+ : { label: status, severity: undefined };
131
+
132
+ const excluded: StateChip | null = rowData?.excluded === true
133
+ ? { label: 'Excluded', severity: 'secondary' }
134
+ : null;
135
+
136
+ return { lifecycle, excluded, live, abandoned, interrupted, settled };
137
+ };
138
+
139
+ /** Every chip that applies to a run, in reading order. Used by the dialog. */
140
+ const stateChipsOf = (rowData: any): StateChip[] => {
141
+ const { lifecycle, excluded } = stateOf(rowData);
142
+ return excluded ? [lifecycle, excluded] : [lifecycle];
143
+ };
144
+
145
+ /**
146
+ * The one chip the Status column has room for.
147
+ *
148
+ * Excluded takes the column from a **settled** run: it is out of the results,
149
+ * nothing is owed to it, and "Completed" is no longer the thing the operator
150
+ * needs to read off the row — so the narrow column spends its one chip on the
151
+ * state that still means something.
152
+ *
153
+ * A lifecycle state that is WAITING ON SOMEONE keeps the column instead, even
154
+ * when the run is excluded: In Progress and Paused because nothing may mask
155
+ * what the machine is doing right now, Interrupted because it is a request to
156
+ * close the run out and hiding it is how a run stays interrupted forever, and
157
+ * Abandoned because it is the reason the row is hidden at all and
158
+ * "Show abandoned" has to explain itself.
159
+ *
160
+ * Whatever loses the column is still on the row's Details dialog, which is
161
+ * where both chips always appear together.
162
+ */
163
+ const statusOf = (rowData: any): StateChip => {
164
+ const { lifecycle, excluded, settled } = stateOf(rowData);
165
+ return excluded && settled ? excluded : lifecycle;
166
+ };
167
+
168
+ /**
169
+ * A run's effective sample_id: top-level wins, falling back to the deprecated
170
+ * nested `config.sample_id` so runs recorded by an older server still show a
171
+ * label. Mirrors the server's `sample_id_of`.
172
+ */
173
+ const sampleIdOf = (rowData: any): string => {
174
+ const top = typeof rowData?.sample_id === 'string' ? rowData.sample_id : '';
175
+ if (top) return top;
176
+ const cfg = rowData?.config;
177
+ if (cfg && typeof cfg === 'object' && typeof cfg.sample_id === 'string') {
178
+ return cfg.sample_id;
179
+ }
180
+ return '';
181
+ };
182
+
183
+ /** Local date/time, or an empty string for an absent stamp. */
184
+ const formatDate = (dateStr: string) => {
185
+ if (!dateStr) return '';
186
+ return new Date(dateStr).toLocaleString();
187
+ };
188
+
92
189
  type DownloadKind = 'data' | 'report';
93
190
  type InFlight = { runId: string; kind: DownloadKind };
94
191
  type ProjectDownloadKind = 'report' | 'archive';
95
192
 
193
+ /** One label/value line in the details dialog. Absent values are omitted. */
194
+ const DetailRow: React.FC<{ label: string; value?: React.ReactNode; mono?: boolean }> =
195
+ ({ label, value, mono }) => {
196
+ if (value === undefined || value === null || value === '') return null;
197
+ return (
198
+ <>
199
+ <dt style={{ color: 'var(--text-color-secondary)', fontSize: '0.9em' }}>{label}</dt>
200
+ <dd style={{ margin: 0, fontFamily: mono ? 'monospace' : undefined }}>{value}</dd>
201
+ </>
202
+ );
203
+ };
204
+
205
+ /** Label/value block. `dl` grid so the values line up down the column. */
206
+ const DetailGrid: React.FC<{ title: string; children: React.ReactNode }> =
207
+ ({ title, children }) => (
208
+ <section style={{ marginTop: '1rem' }}>
209
+ <h4 style={{ margin: '0 0 0.4rem', fontSize: '0.95em' }}>{title}</h4>
210
+ <dl style={{
211
+ display: 'grid',
212
+ gridTemplateColumns: 'minmax(9rem, max-content) 1fr',
213
+ gap: '0.3rem 1rem',
214
+ margin: 0,
215
+ }}>
216
+ {children}
217
+ </dl>
218
+ </section>
219
+ );
220
+
221
+ /**
222
+ * Render one recorded configuration value. These are stored in the method's
223
+ * backend units — the dialog reports what is on the record rather than
224
+ * converting, because the record is what it exists to show. Long floats are
225
+ * trimmed to six significant figures so a column of them stays readable.
226
+ */
227
+ const configValueText = (v: unknown): string => {
228
+ if (v === null || v === undefined) return '—';
229
+ if (typeof v === 'number') {
230
+ return Number.isFinite(v) ? String(Number.parseFloat(v.toPrecision(6))) : String(v);
231
+ }
232
+ if (typeof v === 'boolean') return v ? 'Yes' : 'No';
233
+ if (typeof v === 'object') return JSON.stringify(v);
234
+ return String(v);
235
+ };
236
+
237
+ /**
238
+ * The body of the Details dialog: state, identity, timing, and the recorded
239
+ * configuration. Split out so the state chips and the table's Status column
240
+ * are demonstrably reading the same `stateOf`.
241
+ */
242
+ const TestDetails: React.FC<{ row: any }> = ({ row }) => {
243
+ const chips = stateChipsOf(row);
244
+ const { abandoned } = stateOf(row);
245
+ const config = (row?.config && typeof row.config === 'object') ? row.config : {};
246
+ // sample_id lives at the top level now; an older record kept it in config,
247
+ // where it is identity rather than a setting.
248
+ const configEntries = Object.entries(config).filter(([k]) => k !== 'sample_id');
249
+
250
+ return (
251
+ <div>
252
+ <div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
253
+ {chips.map((c) => <Badge key={c.label} value={c.label} severity={c.severity} />)}
254
+ </div>
255
+
256
+ <DetailGrid title="Identity">
257
+ <DetailRow label="Sample ID" value={sampleIdOf(row)} />
258
+ <DetailRow label="Test Method" value={row?.method_id} />
259
+ <DetailRow label="Project" value={row?.project_id} />
260
+ <DetailRow label="Test ID" value={row?.run_id} mono />
261
+ </DetailGrid>
262
+
263
+ <DetailGrid title="Timing">
264
+ <DetailRow label="Started" value={formatDate(row?.start_time)} />
265
+ <DetailRow label="Ended" value={formatDate(row?.end_time)} />
266
+ <DetailRow label="Paused" value={formatDate(row?.paused_time)} />
267
+ <DetailRow label="Resumed" value={formatDate(row?.resumed_time)} />
268
+ </DetailGrid>
269
+
270
+ {/* Only rendered when one of these actually applies, so an ordinary
271
+ completed run shows no section at all rather than a row of
272
+ dashes explaining nothing. */}
273
+ {(row?.close_reason || row?.excluded === true || abandoned) && (
274
+ <DetailGrid title="State">
275
+ <DetailRow label="Interrupted" value={row?.close_reason} />
276
+ <DetailRow
277
+ label="Excluded"
278
+ value={row?.excluded === true
279
+ ? `Not counted in the ${sampleIdOf(row) || 'sample'} results` +
280
+ (row?.excluded_at ? ` · since ${formatDate(row.excluded_at)}` : '') +
281
+ '. Use the Incl. column to put it back.'
282
+ : undefined}
283
+ />
284
+ <DetailRow
285
+ label="Abandoned"
286
+ value={abandoned
287
+ ? 'Kept on disk, out of the results, and cannot be reopened.'
288
+ : undefined}
289
+ />
290
+ </DetailGrid>
291
+ )}
292
+
293
+ {configEntries.length > 0 && (
294
+ <DetailGrid title="Configuration as recorded">
295
+ {configEntries.map(([k, v]) => (
296
+ <DetailRow key={k} label={k} value={configValueText(v)} />
297
+ ))}
298
+ </DetailGrid>
299
+ )}
300
+ </div>
301
+ );
302
+ };
303
+
96
304
  export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) => {
97
305
  const tis = useTis();
98
306
  const projectId = props.projectId ?? tis.selection.projectId;
@@ -121,6 +329,10 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
121
329
  // Abandoned runs are hidden by default — see `visibleTests`. The toggle
122
330
  // is how they stay auditable without cluttering the operator's list.
123
331
  const [showAbandoned, setShowAbandoned] = useState(false);
332
+ // The row whose Details dialog is open, captured from the menu. A snapshot
333
+ // rather than a run_id lookup: the dialog describes the row the operator
334
+ // opened it from, and closes long before the next refetch matters.
335
+ const [detailsRow, setDetailsRow] = useState<any>(null);
124
336
  const toastRef = useRef<Toast>(null);
125
337
  const { invoke } = useContext(EventEmitterContext);
126
338
 
@@ -179,11 +391,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
179
391
  // eslint-disable-next-line react-hooks/exhaustive-deps
180
392
  }, [menuRequest]);
181
393
 
182
- const formatDate = (dateStr: string) => {
183
- if (!dateStr) return '';
184
- return new Date(dateStr).toLocaleString();
185
- };
186
-
187
394
  const handleDownload = async (rowData: any, kind: DownloadKind) => {
188
395
  const runId = rowData?.run_id;
189
396
  const rowMethodId = rowData?.method_id ?? methodId;
@@ -314,6 +521,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
314
521
  * - finalized (abandoned) → nothing; `finalized` exists precisely to
315
522
  * stop a deliberately-abandoned run being resurrected. Only reachable
316
523
  * at all with `Show abandoned` on.
524
+ *
525
+ * Details is offered on EVERY row, first, and is the one item that is
526
+ * never gated: it is where the full state, the run's own identity and the
527
+ * recorded configuration are legible, and it is what the narrow Status
528
+ * column defers to.
317
529
  */
318
530
  const rowMenuItems = (rowData: any) => {
319
531
  const status = rowData?.status;
@@ -324,7 +536,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
324
536
  // still reads as Interrupted until someone closes it.
325
537
  const isInterrupted =
326
538
  status === 'completed' && rowData?.auto_closed === true && !finalized;
327
- const items: any[] = [];
539
+ const items: any[] = [{
540
+ label: 'Details',
541
+ icon: 'pi pi-info-circle',
542
+ command: () => setDetailsRow(rowData),
543
+ }, { separator: true }];
328
544
 
329
545
  if (isOpen) {
330
546
  items.push({
@@ -405,10 +621,13 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
405
621
  accept: () => handleLifecycle(rowData, 'reopen'),
406
622
  }),
407
623
  });
408
- } else {
624
+ } else if (finalized) {
625
+ // Nothing to offer, but say why rather than leaving the operator to
626
+ // infer it from an absence. A run with no lifecycle action at all
627
+ // still has Details above.
409
628
  items.push({
410
- label: finalized ? 'Abandoned — cannot reopen' : 'No actions available',
411
- icon: 'pi pi-info-circle',
629
+ label: 'Abandoned — cannot reopen',
630
+ icon: 'pi pi-ban',
412
631
  disabled: true,
413
632
  });
414
633
  }
@@ -482,20 +701,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
482
701
  }
483
702
  };
484
703
 
485
- // sample_id is now a top-level field on test.json. Older test.json
486
- // files (written before the rename) carry it nested in `config`; fall
487
- // back to the nested form so we can still display the column for runs
488
- // recorded by an older server.
489
- const sampleIdOf = (rowData: any) => {
490
- const top = typeof rowData?.sample_id === 'string' ? rowData.sample_id : '';
491
- if (top) return top;
492
- const cfg = rowData?.config;
493
- if (cfg && typeof cfg === 'object' && typeof cfg.sample_id === 'string') {
494
- return cfg.sample_id;
495
- }
496
- return '';
497
- };
498
-
499
704
  /**
500
705
  * Abandoning a run is the operator saying "this one never happened":
501
706
  * TIS drops it from the sample aggregates, and it drops out of this list
@@ -509,27 +714,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
509
714
  ? tests
510
715
  : tests.filter((t) => t?.finalized !== true);
511
716
 
512
- // Lifecycle status badge. `status`/`finalized` are written by the TIS
513
- // servelet (start_test → in_progress, pause_test → paused, finish_test →
514
- // completed, reset_test → finalized). Runs recorded before this field
515
- // existed have neither; treat them as completed (they're long since done).
516
- const statusOf = (rowData: any): { label: string; severity: 'success' | 'info' | 'warning' | 'danger' | undefined } => {
517
- if (rowData?.finalized === true) return { label: 'Abandoned', severity: 'danger' };
518
- const status = typeof rowData?.status === 'string' ? rowData.status : 'completed';
519
- // A run TIS closed at startup because the previous process stopped
520
- // mid-test. It is closed (not "In Progress"), but the operator never
521
- // finished it, so calling it "Completed" would overstate what happened.
522
- // Still reopenable — `finalized` is deliberately not set by that sweep.
523
- if (status === 'completed' && rowData?.auto_closed === true) {
524
- return { label: 'Interrupted', severity: 'warning' };
525
- }
526
- switch (status) {
527
- case 'in_progress': return { label: 'In Progress', severity: 'info' };
528
- case 'paused': return { label: 'Paused', severity: 'warning' };
529
- case 'completed': return { label: 'Completed', severity: 'success' };
530
- default: return { label: status, severity: undefined };
531
- }
532
- };
533
717
 
534
718
  return (
535
719
  // Outer wrapper pins the whole component to its container's width.
@@ -568,8 +752,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
568
752
  outlined
569
753
  disabled={!projectId || projectBusy !== null}
570
754
  onClick={() => handleProjectDownload('report')}
571
- tooltip="Download a CSV report of every test in this project"
572
- tooltipOptions={{ position: 'bottom' }}
573
755
  />
574
756
  <Button
575
757
  icon={projectBusy === 'archive' ? 'pi pi-spin pi-spinner' : 'pi pi-box'}
@@ -578,8 +760,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
578
760
  outlined
579
761
  disabled={!projectId || projectBusy !== null}
580
762
  onClick={() => handleProjectDownload('archive')}
581
- tooltip="Download a ZIP of the entire project directory (all tests, raw data, configs)"
582
- tooltipOptions={{ position: 'bottom' }}
583
763
  />
584
764
  <Button
585
765
  icon="pi pi-refresh"
@@ -675,10 +855,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
675
855
  onClick={() => handleToggleExclude(rowData)}
676
856
  aria-label={isExcluded ? 'Excluded — click to include'
677
857
  : 'Included — click to exclude'}
678
- tooltip={isExcluded
679
- ? 'Excluded from cross-test results — click to include'
680
- : 'Included in cross-test results — click to exclude'}
681
- tooltipOptions={{ position: 'left' }}
682
858
  />
683
859
  );
684
860
  }}
@@ -699,8 +875,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
699
875
  outlined
700
876
  disabled={anyBusy}
701
877
  onClick={() => handleDownload(rowData, 'data')}
702
- tooltip="Download raw + filtered trace data as one CSV"
703
- tooltipOptions={{ position: 'left' }}
704
878
  />
705
879
  <Button
706
880
  icon={isReportBusy ? 'pi pi-spin pi-spinner' : 'pi pi-file'}
@@ -709,8 +883,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
709
883
  outlined
710
884
  disabled={anyBusy}
711
885
  onClick={() => handleDownload(rowData, 'report')}
712
- tooltip="Download a CSV report (metadata + cycles + results) for this test"
713
- tooltipOptions={{ position: 'left' }}
714
886
  />
715
887
  </div>
716
888
  );
@@ -733,8 +905,6 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
733
905
  size="small"
734
906
  disabled={lifecycleBusy !== null}
735
907
  aria-label="Test actions"
736
- tooltip="Complete / abandon / reopen this test"
737
- tooltipOptions={{ position: 'left' }}
738
908
  onClick={(e) => {
739
909
  // Capture the DOM anchor now: React clears
740
910
  // currentTarget once the handler returns, and the
@@ -762,6 +932,22 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
762
932
  control bar, and a message that covers Start/Stop for four
763
933
  seconds is worse than no message at all. */}
764
934
  <Toast ref={toastRef} position="top-right" />
935
+
936
+ {/* Per-run detail. This is where everything the table has no room
937
+ for is legible: the full state (a Completed run that is also
938
+ Excluded shows both chips), the run's own identity, and the
939
+ configuration it was recorded under. It replaces the tooltips
940
+ this component used to explain itself with — a hover hint is
941
+ unreachable on the touchscreen these machines ship with. */}
942
+ <Dialog
943
+ header="Test Details"
944
+ visible={detailsRow !== null}
945
+ onHide={() => setDetailsRow(null)}
946
+ style={{ width: 'min(44rem, 94vw)' }}
947
+ dismissableMask
948
+ >
949
+ {detailsRow && <TestDetails row={detailsRow} />}
950
+ </Dialog>
765
951
  </div>
766
952
  );
767
953
  };