I decided this was a good topic to play with via an LLM. As I already pay for Grok, that is what I used for the following.
On the first prompt I attempted to attach my v83 dev instance's openapi JSON download, but it didn't "take", and I didn't notice. Here we go, first prompt and response:
Prompt #1
Given the API provided in the attachment, please construct a React user interface with the TypeScript language that uses the "List Device Resources" endpoint to enumerate the devices present in the server. Display as a table, with columns for type, name, description, and data. The "data" column is to display its content as a nested table of the string file names. Each string file name in the nested table for "data" shall be a link to download that file using the "Get Device Data File" endpoint. Adjacent to each data file name, place an icon that will launch a popup component containing a file upload field, to be used to replace that specific data file with new content, using the "Update Device Data File" endpoint.
Response #1, 59s
Response #1 details
To address your query, I'll construct a React user interface using TypeScript that interacts with the specified server API endpoints to manage devices and their data files. The interface will list devices in a table, display their data files in a nested table with download links, and allow file replacement through a popup. Since the attachment detailing the exact API isn't provided, I'll base the solution on reasonable assumptions derived from the query, aligning with the endpoints mentioned: "List Device Resources," "Get Device Data File," and "Update Device Data File."
Objective
Use the "List Device Resources" endpoint to fetch and display devices in a table with columns: type, name, description, and data.
In the data column, show a nested table of file names.
Make each file name a downloadable link using the "Get Device Data File" endpoint.
Place an icon next to each file name to launch a popup for uploading a new file via the "Update Device Data File" endpoint.
Assumptions
Since the exact API details are unavailable, I’ll assume the following endpoints based on standard REST conventions:
List Device Resources: GET /devices - Returns a JSON array of devices.
Get Device Data File: GET /devices/{deviceId}/data/{fileName} - Downloads a file.
Update Device Data File: PUT /devices/{deviceId}/data/{fileName} - Uploads a new file to replace the existing one.
Each device object is assumed to have:
id: string (unique identifier)
type: string
name: string
description: string
data: string[] (array of file names)
Example response from GET /devices:
[
{
"id": "device1",
"type": "sensor",
"name": "Temperature Sensor",
"description": "Measures temperature",
"data": ["temp1.txt", "temp2.txt"]
},
{
"id": "device2",
"type": "camera",
"name": "Security Camera",
"description": "Records video",
"data": ["video1.mp4"]
}
]
Solution
Components
We’ll create three main components:
DeviceTable: Fetches and displays the device list.
DataFileList: Renders the nested table of data files with download links and upload icons.
FileUploadPopup: Handles file selection and upload in a popup.
Code Implementation
import React, { useState, useEffect } from 'react';
interface Device {
id: string;
type: string;
name: string;
description: string;
data: string[];
}
/**
* DeviceTable Component
* Fetches and displays devices in a table
*/
const DeviceTable: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]);
useEffect(() => {
fetch('/devices')
.then((response) => {
if (!response.ok) throw new Error('Failed to fetch devices');
return response.json();
})
.then((data: Device[]) => setDevices(data))
.catch((error) => console.error('Error fetching devices:', error));
}, []);
return (
<table style={{ borderCollapse: 'collapse', width: '100%' }}>
<thead>
<tr>
<th style={{ border: '1px solid #ddd', padding: '8px' }}>Type</th>
<th style={{ border: '1px solid #ddd', padding: '8px' }}>Name</th>
<th style={{ border: '1px solid #ddd', padding: '8px' }}>Description</th>
<th style={{ border: '1px solid #ddd', padding: '8px' }}>Data</th>
</tr>
</thead>
<tbody>
{devices.map((device) => (
<tr key={device.id}>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{device.type}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{device.name}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>{device.description}</td>
<td style={{ border: '1px solid #ddd', padding: '8px' }}>
<DataFileList deviceId={device.id} files={device.data} />
</td>
</tr>
))}
</tbody>
</table>
);
};
/**
* DataFileList Component
* Displays a nested table of file names with download links and upload buttons
*/
interface DataFileListProps {
deviceId: string;
files: string[];
}
const DataFileList: React.FC<DataFileListProps> = ({ deviceId, files }) => {
const [selectedFileForUpload, setSelectedFileForUpload] = useState<string | null>(null);
return (
<>
<table>
<tbody>
{files.map((file) => (
<tr key={file}>
<td>
<a href={`/devices/${deviceId}/data/${file}`} download={file}>
{file}
</a>
</td>
<td>
<button
onClick={() => setSelectedFileForUpload(file)}
style={{ cursor: 'pointer', padding: '0 5px' }}
title="Upload new file"
>
📤
</button>
</td>
</tr>
))}
</tbody>
</table>
{selectedFileForUpload && (
<FileUploadPopup
deviceId={deviceId}
fileName={selectedFileForUpload}
onClose={() => setSelectedFileForUpload(null)}
/>
)}
</>
);
};
/**
* FileUploadPopup Component
* Popup for uploading a new file to replace an existing one
*/
interface FileUploadPopupProps {
deviceId: string;
fileName: string;
onClose: () => void;
}
const FileUploadPopup: React.FC<FileUploadPopupProps> = ({ deviceId, fileName, onClose }) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setSelectedFile(event.target.files[0]);
}
};
const handleUpload = () => {
if (!selectedFile) {
alert('Please select a file to upload');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
fetch(`/devices/${deviceId}/data/${fileName}`, {
method: 'PUT',
body: formData,
})
.then((response) => {
if (response.ok) {
alert('File uploaded successfully');
onClose();
} else {
throw new Error('Upload failed');
}
})
.catch((error) => {
console.error('Error uploading file:', error);
alert('Failed to upload file');
});
};
return (
<div
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'white',
padding: '20px',
border: '1px solid #ccc',
boxShadow: '0 0 10px rgba(0,0,0,0.3)',
zIndex: 1000,
}}
>
<h3>Replace {fileName}</h3>
<input type="file" onChange={handleFileChange} style={{ display: 'block', margin: '10px 0' }} />
<button onClick={handleUpload} style={{ marginRight: '10px' }}>
Upload
</button>
<button onClick={onClose}>Cancel</button>
</div>
);
};
/**
* App Component
* Main entry point rendering the DeviceTable
*/
const App: React.FC = () => {
return (
<div style={{ padding: '20px' }}>
<h1>Device Management</h1>
<DeviceTable />
</div>
);
};
export default App;
Explanation
DeviceTable
Purpose: Fetches the device list from GET /devices when mounted using useEffect.
Display: Renders a table with columns type, name, description, and data.
Props to DataFileList: Passes each device’s id and data (file names) to DataFileList.
Styling: Basic inline styles ensure a readable table layout.
DataFileList
Purpose: Displays the data array as a nested table.
Features:
Download Link: Each file name is an <a> tag with href set to /devices/{deviceId}/data/{file}, and the download attribute ensures the file is downloaded rather than navigated to.
Upload Icon: A button with an upload emoji (📤) triggers the popup. In a production app, you might use an icon library like Material-UI.
State: selectedFileForUpload tracks which file’s popup is open, rendering FileUploadPopup conditionally.
Styling: Minimal styling keeps the nested table compact.
FileUploadPopup
Purpose: Provides an interface to upload a new file.
Features:
File Input: Uses <input type="file"> with an onChange handler to capture the selected file.
Upload: Sends a PUT request to /devices/{deviceId}/data/{fileName} with the file in a FormData object.
Feedback: Alerts on success or failure; closes on success or cancel.
Styling: Inline styles position it as a centered popup with a shadow effect.
App
Purpose: Serves as the entry point, wrapping DeviceTable with a title.
Usage
Listing Devices: On load, the app fetches devices and displays them.
Downloading Files: Click a file name in the data column to download it.
Uploading Files: Click the 📤 icon next to a file name, select a file in the popup, and click "Upload" to replace the file.
Notes
Error Handling: Basic error handling is included (e.g., fetch errors logged, upload failures alerted). Enhance with loading spinners or toast notifications in a real app.
Styling: Inline styles are used for simplicity. In practice, use CSS files or a library like styled-components.
TypeScript: Interfaces ensure type safety for device data and component props.
API Assumptions: Adjust endpoint URLs and request/response handling based on the actual API specification if it differs.
This solution fully meets the requirements to list devices, display data files with download links, and enable file replacement via a popup, all built with React and TypeScript.
Well. I noticed right away that it didn't pick up the API, but proceeded with an assumed API structure. Not bad.
I added .txt
to the API JSON's file name and attached it successfully. Then asked Grok to continue:
Prompt #2
Update your solution using the API in the attachment.
Response, 1m 9s
Response #2 details
Solution: Updating the React Application to Use the Ignition HTTP API
The user query asks to update a solution based on the provided Ignition HTTP API specification. The original intent, as inferred from the context, appears to be a React application with TypeScript that lists devices and their associated data files, allowing users to download and update these files. Since the API specification provided does not explicitly include endpoints like GET /data/api/v1/devices or GET /data/api/v1/devices/{deviceId}/data as initially assumed, we need to adapt the solution using the available endpoints, particularly focusing on /data/api/v1/entity/browse, which seems most relevant for listing hierarchical entities such as devices and their resources.
Below, I’ll outline the updated solution, leveraging the /data/api/v1/entity/browse endpoint to enumerate devices and their data files, and make reasonable assumptions where the API lacks explicit endpoints for downloading and updating files.
Assumptions Based on the API Specification
Devices as Entities: In the Ignition platform, devices (e.g., OPC UA devices) are likely registered as entities. The /data/api/v1/entity/browse endpoint can be used to list these entities, assuming devices are organized under a specific path (e.g., opcua/devices).
Data Files as Child Entities: The "data files" associated with devices are assumed to be child entities of the device entities, accessible by browsing deeper into the entity hierarchy.
Downloading Files: The API does not provide a direct endpoint to download entity content. I’ll assume that file entities have a navigation.actions property with a "download" action providing a URL, or that GET /data/api/v1/entity/browse with a specific path returns file metadata with a downloadable reference.
Updating Files: No explicit file upload endpoint exists. I’ll assume a hypothetical PUT /data/api/v1/entity/{path} could update an entity’s content, though this is speculative as the API only supports enabling/disabling entities via /data/api/v1/entity/enabled.
Authentication: The API supports API tokens (via /data/api/v1/api-token/generate). I’ll include an Authorization header with a Bearer token, assuming the token is pre-obtained.
Updated Approach
List Devices: Use GET /data/api/v1/entity/browse?path=opcua/devices&depth=1 to fetch a list of device entities.
List Data Files: For each device, use GET /data/api/v1/entity/browse?path=opcua/devices/{deviceName}&depth=1 to fetch its child entities (data files).
Download Files: Assume each file entity’s navigation.actions includes a "download" action with a URL.
Update Files: Hypothetically use PUT /data/api/v1/entity/{path} to upload a new file, acknowledging this is an assumption due to API limitations.
TypeScript Interfaces
First, define the data structures based on the Entity schema from the API:
interface Entity {
path: string;
name: string;
description?: {
description?: string;
iconUrl?: string;
details?: Record<string, any>;
};
diagnostics?: {
metrics?: Record<string, any>;
healthchecks?: Record<string, any>;
};
enablement?: {
enabled: boolean;
enabledStateMutable: boolean;
};
audit?: {
lastModified?: number;
lastModifiedBy?: string;
};
navigation?: {
section?: string;
actions?: { action: string; url: string }[];
};
children?: {
total: number;
offset: number;
limit: number;
entities: Entity[];
};
}
interface Device extends Entity {
// Assuming devices have identifiable properties in 'details'
type?: string; // e.g., "sensor", "camera" from description.details
}
interface DataFile extends Entity {
// Data files are child entities with a name (filename)
}
React Components
- DeviceTable Component
Fetches and displays the list of devices.
import React, { useState, useEffect } from 'react';
import DataFileList from './DataFileList';
const DeviceTable: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]);
const apiToken = 'your-api-token-here'; // Replace with actual token management
useEffect(() => {
fetch('/data/api/v1/entity/browse?path=opcua/devices&depth=1', {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Accept': 'application/json',
},
})
.then(response => {
if (!response.ok) throw new Error('Failed to fetch devices');
return response.json();
})
.then((data: Entity[]) => {
// Map entities to Device type, assuming type is in details
const deviceList = data.map(entity => ({
...entity,
type: entity.description?.details?.type || 'unknown',
}));
setDevices(deviceList);
})
.catch(error => console.error('Error fetching devices:', error));
}, []);
return (
<table>
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Description</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{devices.map(device => (
<tr key={device.path}>
<td>{device.type}</td>
<td>{device.name}</td>
<td>{device.description?.description || 'N/A'}</td>
<td>
<DataFileList devicePath={device.path} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default DeviceTable;
- DataFileList Component
Fetches and displays data files for a given device, with download and upload options.
import React, { useState, useEffect } from 'react';
import FileUploadPopup from './FileUploadPopup';
interface DataFileListProps {
devicePath: string;
}
const DataFileList: React.FC<DataFileListProps> = ({ devicePath }) => {
const [files, setFiles] = useState<DataFile[]>([]);
const [selectedFileForUpload, setSelectedFileForUpload] = useState<string | null>(null);
const apiToken = 'your-api-token-here';
useEffect(() => {
fetch(`/data/api/v1/entity/browse?path=${encodeURIComponent(devicePath)}&depth=1`, {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Accept': 'application/json',
},
})
.then(response => {
if (!response.ok) throw new Error('Failed to fetch files');
return response.json();
})
.then((data: Entity[]) => {
// Assume children.entities are the data files
const fileEntities = data[0]?.children?.entities || [];
setFiles(fileEntities);
})
.catch(error => console.error('Error fetching files:', error));
}, [devicePath]);
const getDownloadUrl = (file: DataFile) => {
// Find 'download' action in navigation.actions
return file.navigation?.actions?.find(action => action.action === 'download')?.url || '#';
};
return (
<>
<table>
<tbody>
{files.map(file => (
<tr key={file.path}>
<td>
<a href={getDownloadUrl(file)} download={file.name}>
{file.name}
</a>
</td>
<td>
<button onClick={() => setSelectedFileForUpload(file.path)}>📤</button>
</td>
</tr>
))}
</tbody>
</table>
{selectedFileForUpload && (
<FileUploadPopup
filePath={selectedFileForUpload}
onClose={() => setSelectedFileForUpload(null)}
/>
)}
</>
);
};
export default DataFileList;
- FileUploadPopup Component
Handles file selection and upload.
import React, { useState } from 'react';
interface FileUploadPopupProps {
filePath: string;
onClose: () => void;
}
const FileUploadPopup: React.FC<FileUploadPopupProps> = ({ filePath, onClose }) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const apiToken = 'your-api-token-here';
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setSelectedFile(event.target.files[0]);
}
};
const handleUpload = () => {
if (!selectedFile) {
alert('Please select a file to upload');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
// Hypothetical endpoint; adjust based on actual API
fetch(`/data/api/v1/entity/${encodeURIComponent(filePath)}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${apiToken}`,
},
body: formData,
})
.then(response => {
if (!response.ok) throw new Error('Upload failed');
alert('File uploaded successfully');
onClose();
})
.catch(error => {
console.error('Error uploading file:', error);
alert('Failed to upload file');
});
};
return (
<div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'white', padding: '20px', border: '1px solid #ccc' }}>
<h3>Replace {filePath.split('/').pop()}</h3>
<input type="file" onChange={handleFileChange} />
<button onClick={handleUpload}>Upload</button>
<button onClick={onClose}>Cancel</button>
</div>
);
};
export default FileUploadPopup;
- App Component
The root component.
import React from 'react';
import DeviceTable from './DeviceTable';
const App: React.FC = () => {
return (
<div>
<h1>Device Management</h1>
<DeviceTable />
</div>
);
};
export default App;
Notes and Limitations
Path Assumption: The path opcua/devices is assumed based on the OPC UA context in Ignition. This should be replaced with the actual path where devices are registered, which requires system-specific knowledge.
File Download: The assumption that navigation.actions contains a "download" action is speculative. If incorrect, an alternative endpoint or manual URL construction is needed.
File Upload: The PUT /data/api/v1/entity/{path} endpoint is hypothetical. The actual API may require a different mechanism (e.g., a dedicated file management endpoint not present in the spec).
Error Handling: Basic error handling is included, but production code should include retries, loading states, and user feedback.
API Token: Hardcoding the token is for demonstration; use a secure context or environment variable in practice.
Conclusion
This updated solution uses the /data/api/v1/entity/browse endpoint to list devices and their data files within a React application, aligning with the provided Ignition HTTP API. Due to gaps in the API for file operations, assumptions were made, which should be validated against the full system documentation or extended API specifications.
Interesting. Went off into the weeds even though my first prompt told it what endpoints to use. Then I smacked my forehead because I used the summary names of the endpoints, and Grok didn't grok that. 
Prompt #3
In my first prompt, I specified endpoints via their API summary name that should provide the necessary enumeration, download, and upload services. Please update your solution to use those instead of the Entity-based solution.
Response, 17s
Response #3 details
Based on your clarification that the endpoints should be specified by their API summary names—List Device Resources, Get Device Data File, and Update Device Data File—I’ve updated the solution to use these endpoints as follows:
List Device Resources: Assumed as GET /data/api/v1/devices to enumerate devices.
Get Device Data File: Assumed as GET /data/api/v1/devices/{deviceId}/data/{fileName} to download a specific file.
Update Device Data File: Assumed as PUT /data/api/v1/devices/{deviceId}/data/{fileName} to upload or replace a file.
These assumptions align with typical RESTful API designs for resource management, given that the exact endpoint URLs weren’t provided in the API specification but are implied by their summary names. Below is the complete, updated solution implemented in TypeScript with React components.
TypeScript Interfaces
Define the data structure for devices based on the assumed API responses:
interface Device {
id: string;
type: string;
name: string;
description: string;
data: string[]; // Array of file names
}
React Components
- DeviceTable Component
Fetches and displays the list of devices using List Device Resources (GET /data/api/v1/devices).
import React, { useState, useEffect } from 'react';
import DataFileList from './DataFileList';
const DeviceTable: React.FC = () => {
const [devices, setDevices] = useState<Device[]>([]);
const apiToken = 'your-api-token-here'; // Replace with secure token management
useEffect(() => {
fetch('/data/api/v1/devices', {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Accept': 'application/json',
},
})
.then(response => {
if (!response.ok) throw new Error('Failed to fetch devices');
return response.json();
})
.then((data: Device[]) => setDevices(data))
.catch(error => console.error('Error fetching devices:', error));
}, []);
return (
<table>
<thead>
<tr>
<th>Type</th>
<th>Name</th>
<th>Description</th>
<th>Data</th>
</tr>
</thead>
<tbody>
{devices.map(device => (
<tr key={device.id}>
<td>{device.type}</td>
<td>{device.name}</td>
<td>{device.description}</td>
<td>
<DataFileList deviceId={device.id} files={device.data} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default DeviceTable;
- DataFileList Component
Displays data files for a device with download links (using Get Device Data File) and upload buttons.
import React, { useState } from 'react';
import FileUploadPopup from './FileUploadPopup';
interface DataFileListProps {
deviceId: string;
files: string[];
}
const DataFileList: React.FC<DataFileListProps> = ({ deviceId, files }) => {
const [selectedFileForUpload, setSelectedFileForUpload] = useState<string | null>(null);
return (
<>
<table>
<tbody>
{files.map(file => (
<tr key={file}>
<td>
<a href={`/data/api/v1/devices/${deviceId}/data/${file}`} download={file}>
{file}
</a>
</td>
<td>
<button onClick={() => setSelectedFileForUpload(file)}>📤</button>
</td>
</tr>
))}
</tbody>
</table>
{selectedFileForUpload && (
<FileUploadPopup
deviceId={deviceId}
fileName={selectedFileForUpload}
onClose={() => setSelectedFileForUpload(null)}
/>
)}
</>
);
};
export default DataFileList;
- FileUploadPopup Component
Handles file selection and upload using Update Device Data File (PUT /data/api/v1/devices/{deviceId}/data/{fileName}).
import React, { useState } from 'react';
interface FileUploadPopupProps {
deviceId: string;
fileName: string;
onClose: () => void;
}
const FileUploadPopup: React.FC<FileUploadPopupProps> = ({ deviceId, fileName, onClose }) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const apiToken = 'your-api-token-here'; // Replace with secure token management
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
setSelectedFile(event.target.files[0]);
}
};
const handleUpload = () => {
if (!selectedFile) {
alert('Please select a file to upload');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
fetch(`/data/api/v1/devices/${deviceId}/data/${fileName}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${apiToken}`,
},
body: formData,
})
.then(response => {
if (!response.ok) throw new Error('Upload failed');
alert('File uploaded successfully');
onClose();
})
.catch(error => {
console.error('Error uploading file:', error);
alert('Failed to upload file');
});
};
return (
<div style={{ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', background: 'white', padding: '20px', border: '1px solid #ccc' }}>
<h3>Replace {fileName}</h3>
<input type="file" onChange={handleFileChange} />
<button onClick={handleUpload}>Upload</button>
<button onClick={onClose}>Cancel</button>
</div>
);
};
export default FileUploadPopup;
- App Component
The root component that ties everything together.
import React from 'react';
import DeviceTable from './DeviceTable';
const App: React.FC = () => {
return (
<div>
<h1>Device Management</h1>
<DeviceTable />
</div>
);
};
export default App;
Explanation of the Solution
DeviceTable:
Uses List Device Resources (GET /data/api/v1/devices) to fetch the list of devices.
Displays each device’s type, name, description, and a nested DataFileList for its data files.
DataFileList:
Renders each file as a clickable link using Get Device Data File (GET /data/api/v1/devices/{deviceId}/data/{fileName}) for downloading.
Includes an upload button (📤) that opens a FileUploadPopup for replacing the file.
FileUploadPopup:
Allows the user to select a new file and uploads it using Update Device Data File (PUT /data/api/v1/devices/{deviceId}/data/{fileName}).
Sends the file in a FormData object as the request body.
App:
Serves as the entry point, rendering the DeviceTable within a simple layout.
Notes
Endpoint Mapping:
List Device Resources: GET /data/api/v1/devices
Get Device Data File: GET /data/api/v1/devices/{deviceId}/data/{fileName}
Update Device Data File: PUT /data/api/v1/devices/{deviceId}/data/{fileName}
These are assumed based on the summary names and standard REST conventions. Adjust the URLs if your API defines them differently.
Authentication:
The apiToken is hardcoded here for simplicity. In a real application, manage it securely (e.g., via environment variables or a context provider).
Error Handling:
Basic error checks are included (e.g., checking response.ok). For production, add loading states, retries, and better user feedback.
Styling:
Inline styles are used in FileUploadPopup for demonstration. Use CSS, styled-components, or a UI library for a polished look.
This solution now uses the endpoints as specified by their API summary names, replacing the earlier entity-based approach, and provides a functional interface for enumerating devices, downloading files, and uploading replacements. Let me know if further adjustments are needed based on your specific API details!
This looks usable. 
Now to poke at some build infrastructure to try this for real. I'll report back.
{ Tweak recommendations, particularly for making this look like it belongs in the v8.3 web UI, are appreciated. }