Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
341 changes: 205 additions & 136 deletions queries/cdmq/cdm.js

Large diffs are not rendered by default.

146 changes: 121 additions & 25 deletions queries/cdmq/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,32 @@ try {
var logFile = logDir + '/cdm-server.log';
var logStream = fs.createWriteStream(logFile, { flags: 'a' });

function serverLog(msg) {
var line = '[' + new Date().toISOString() + '] ' + msg;
function serverLog(msg, reqId) {
var prefix = '[' + new Date().toISOString() + ']';
if (reqId) prefix += ' [' + reqId + ']';
var line = prefix + ' ' + msg;
console.log(line);
logStream.write(line + '\n');
}

function serverError(msg) {
var line = '[' + new Date().toISOString() + '] ERROR: ' + msg;
function serverError(msg, reqId) {
var prefix = '[' + new Date().toISOString() + ']';
if (reqId) prefix += ' [' + reqId + ']';
var line = prefix + ' ERROR: ' + msg;
console.error(line);
logStream.write(line + '\n');
}

// Per-client request counter for generating short session-like IDs
var clientCounters = {};
function generateReqId(req) {
var ip = req.ip || req.connection.remoteAddress || 'unknown';
var shortIp = ip.replace(/^.*:/, ''); // last part of IPv6 or IPv4
if (!clientCounters[shortIp]) clientCounters[shortIp] = 0;
clientCounters[shortIp]++;
return shortIp + '-' + clientCounters[shortIp];
}

function save_host(host) {
var host_info = { host: host, header: { 'Content-Type': 'application/json' } };
instances.push(host_info);
Expand Down Expand Up @@ -87,6 +101,12 @@ serverLog('Instance info after discovery: ' + JSON.stringify(instances, null, 2)
app.use(cors());
app.use(express.json());

// Assign a request ID to each request for log correlation
app.use(function (req, res, next) {
req.reqId = generateReqId(req);
next();
});

// --------------------------------------------------------------------------------------------------------------
// Middleware: resolve a run ID to an OpenSearch instance and yearDotMonth
// Attaches req.cdm = { instance, yearDotMonth, runId } on success
Expand Down Expand Up @@ -1122,6 +1142,96 @@ app.post('/api/v1/iterations/breakout-values', async (req, res) => {
// Body: { runIds: [...], start, end, source, type, breakout: [...] }
// Returns: { values: { iterationId: { labels: { label: { mean, stddevPct, sampleValues } }, remainingBreakouts: [...] } } }
// When breakout is empty, returns a single label "__all__" with the aggregated value.
// --------------------------------------------------------------------------------------------------------------
// POST /api/v1/iterations/period-info — get period IDs and time ranges per iteration
// Body: { iterations: [{iterationId, runId}], start, end, sampleIndex }
// Returns: { periods: { iterationId: { periodId, begin, end, runId } } }
// --------------------------------------------------------------------------------------------------------------
app.post('/api/v1/iterations/period-info', async (req, res) => {
try {
const { iterations: reqIterations, start, end, sampleIndex } = req.body;
if (!Array.isArray(reqIterations) || reqIterations.length === 0) {
return res.status(400).json({ code: 'MISSING_PARAMS', error: 'iterations array is required' });
}
var requestedSampleIdx = (typeof sampleIndex === 'number') ? sampleIndex : null;
var perIterSampleIdx = (typeof sampleIndex === 'object' && sampleIndex !== null && !Array.isArray(sampleIndex)) ? sampleIndex : null;

getInstancesInfo(instances);
var result = {};

for (const inst of instances) {
if (invalidInstance(inst)) continue;
var ydm = cdm.buildYearDotMonthRange(inst, 'run', start || null, end || null);

var allIterIds = reqIterations.map(function (it) { return it.iterationId; });
var iterRunIds = reqIterations.map(function (it) { return it.runId; });

var samples = await cdm.mgetSamples(inst, allIterIds, ydm);
var statuses = await cdm.mgetSampleStatuses(inst, samples || [], ydm);
if (typeof statuses === 'undefined') statuses = [];
var periodNames = await cdm.mgetPrimaryPeriodName(inst, allIterIds, ydm);

var passingSamplesByIter = [];
var passingPeriodNamesByIter = [];
for (var i = 0; i < allIterIds.length; i++) {
var iterSamples = (samples && samples[i]) || [];
var iterStatuses = (statuses && statuses[i]) || [];
var iterPeriodName = (periodNames && periodNames[i]) || null;
var passing = [];
for (var s = 0; s < iterSamples.length; s++) {
if (iterStatuses[s] === 'pass') passing.push(iterSamples[s]);
}
passingSamplesByIter.push(passing);
passingPeriodNamesByIter.push(iterPeriodName);
}

var primaryPeriodIds = [];
var hasPassing = passingSamplesByIter.some(function (s) { return s.length > 0; });
if (hasPassing) {
primaryPeriodIds = await cdm.mgetPrimaryPeriodId(inst, passingSamplesByIter, passingPeriodNamesByIter, ydm);
if (typeof primaryPeriodIds === 'undefined') primaryPeriodIds = [];
}

var periodRanges = [];
if (primaryPeriodIds.length > 0) {
periodRanges = await cdm.mgetPeriodRange(inst, primaryPeriodIds, ydm);
if (typeof periodRanges === 'undefined') periodRanges = [];
}

for (var i = 0; i < allIterIds.length; i++) {
var iterPeriodIds = (primaryPeriodIds[i]) || [];
var iterRanges = (periodRanges[i]) || [];
if (iterPeriodIds.length === 0) continue;

var selIdx = 0;
if (perIterSampleIdx && perIterSampleIdx[allIterIds[i]] != null) {
selIdx = perIterSampleIdx[allIterIds[i]];
} else if (requestedSampleIdx !== null) {
selIdx = requestedSampleIdx;
}
if (selIdx >= iterPeriodIds.length) selIdx = 0;

if (!iterPeriodIds[selIdx]) continue;
var range = iterRanges[selIdx];
if (!range || !range.begin || !range.end) continue;

result[allIterIds[i]] = {
periodId: iterPeriodIds[selIdx],
begin: range.begin,
end: range.end,
runId: iterRunIds[i],
};
}
}

serverLog('POST /api/v1/iterations/period-info: ' + Object.keys(result).length + ' period(s)');
res.json({ periods: result });
} catch (error) {
serverError('Error in POST /api/v1/iterations/period-info: ' + error);
res.status(500).json({ code: 'INTERNAL_ERROR', error: 'Failed to get period info: ' + error.message });
}
});

// --------------------------------------------------------------------------------------------------------------
app.post('/api/v1/iterations/supplemental-metric', async (req, res) => {
try {
Expand Down Expand Up @@ -1448,18 +1558,10 @@ app.post('/api/v1/metric-data', async (req, res) => {
try {
var { run, period, begin, end, source, type, resolution, breakout, filter, instances: reqInstances } = req.body;

serverLog('[' + Date.now() + '] Fetching metric data with parameters:', {
run,
period,
begin,
end,
source,
type,
resolution,
breakout,
filter,
instances: reqInstances ? `${reqInstances.length} instance(s) provided` : 'using server instances'
});
var reqStart = Date.now();
var breakoutStr = Array.isArray(breakout) ? breakout.join(',') : (breakout || 'none');
serverLog('POST /api/v1/metric-data: ' + source + '::' + type + ' resolution=' + resolution + ' breakout=[' + breakoutStr + ']' + (filter ? ' filter=' + filter : '') + ' run=' + (run || 'none').toString().substring(0, 8) + '... period=' + (period || 'none').toString().substring(0, 8) + '...', req.reqId);
serverLog(' curl: curl -s -X POST http://localhost:3000/api/v1/metric-data -H "Content-Type: application/json" -d \'' + JSON.stringify({ run: run, period: period, begin: begin, end: end, source: source, type: type, resolution: resolution, breakout: breakout, filter: filter }) + '\'', req.reqId);

// Use instances from request if provided, otherwise use server's configured instances
var instancesToUse = reqInstances && reqInstances.length > 0 ? reqInstances : instances;
Expand Down Expand Up @@ -1537,15 +1639,9 @@ app.post('/api/v1/metric-data', async (req, res) => {
}
metric_data = resp['data-sets'][0];

console.log(
'[' +
Date.now() +
'] Request completed from Opensearch instance: ' +
instance['host'] +
' and cdm: ' +
instance['ver'] +
'\n'
);
var labelCount = metric_data && metric_data.values ? Object.keys(metric_data.values).length : 0;
var elapsed = Date.now() - reqStart;
serverLog('POST /api/v1/metric-data: ' + source + '::' + type + ' -> ' + labelCount + ' label(s) in ' + elapsed + 'ms', req.reqId);

// Return the data
res.json(metric_data);
Expand Down
36 changes: 26 additions & 10 deletions queries/cdmq/start-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,42 @@ if ! command -v npm >/dev/null 2>&1; then
popd >/dev/null
exit 1
fi
echo "Resolving cdmq dependencies..."
npm install --no-fund --no-audit 2>&1 | tail -1
# Install dependencies only when package-lock.json is newer than last install
if [ ! -f "node_modules/.install-stamp" ] || [ "package-lock.json" -nt "node_modules/.install-stamp" ]; then
echo "Installing cdmq dependencies..."
npm ci --no-fund --no-audit 2>&1 | tail -1
touch node_modules/.install-stamp
else
echo "cdmq dependencies up to date"
fi

# Build the web UI if source exists
if [ -d "web-ui" ] && [ -f "web-ui/package.json" ]; then
echo "Building web UI..."
pushd web-ui >/dev/null
npm install --no-fund --no-audit 2>&1 | tail -1
node node_modules/.bin/vite build 2>&1
build_rc=$?
popd >/dev/null
if [ $build_rc -ne 0 ]; then
echo "Warning: web UI build failed (rc=$build_rc), server will start without UI"
if [ ! -f "node_modules/.install-stamp" ] || [ "package-lock.json" -nt "node_modules/.install-stamp" ]; then
echo "Installing web UI dependencies..."
npm ci --no-fund --no-audit 2>&1 | tail -1
touch node_modules/.install-stamp
fi
# Rebuild if any source file is newer than the dist
if [ ! -d "dist" ] || [ -n "$(find src -newer dist/index.html 2>/dev/null | head -1)" ] || [ "package-lock.json" -nt "dist/index.html" ]; then
echo "Building web UI..."
node node_modules/.bin/vite build 2>&1
build_rc=$?
if [ $build_rc -ne 0 ]; then
echo "Warning: web UI build failed (rc=$build_rc), server will start without UI"
else
echo "Web UI built successfully"
fi
else
echo "Web UI built successfully"
echo "Web UI build up to date"
fi
popd >/dev/null
fi

while true; do
echo "Starting server.js..."
#CDM_LOG_OS_CURL=1 node ./server.js "$@"
node ./server.js "$@"
rc=$?
echo "server.js exited with rc=$rc, restarting..."
Expand Down
19 changes: 14 additions & 5 deletions queries/cdmq/web-ui/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import SearchPanel from './components/SearchPanel';
import SelectionBar from './components/SelectionBar';
import IterationTable from './components/IterationTable';
import CompareView from './components/CompareView';
import DeepDiveView from './components/DeepDiveView';
import DebugConsole from './components/DebugConsole';
import './index.css';

Expand Down Expand Up @@ -67,6 +68,8 @@ export default function App() {
const lastFilters = useRef(null);
const restoredState = useRef(null);
const [restoredMetrics, setRestoredMetrics] = useState(null);
const [deepDiveMetrics, setDeepDiveMetrics] = useState(new Set()); // Set of "source::type" strings
const [deepDiveConfigs, setDeepDiveConfigs] = useState([]); // snapshot of supplemental metrics for deep dive

// On mount, check for state in URL hash
// Don't switch view yet — wait until search completes and selections are applied
Expand Down Expand Up @@ -207,10 +210,16 @@ export default function App() {
</button>
<button
className={view === 'deepdive' ? 'active' : ''}
onClick={() => setView('deepdive')}
disabled={selected.size === 0}
onClick={() => {
// Snapshot supplemental metric configs before CompareView unmounts
if (compareRef.current) {
setDeepDiveConfigs(compareRef.current.getSupplementalMetrics() || []);
}
setView('deepdive');
}}
disabled={selected.size === 0 || deepDiveMetrics.size === 0}
>
Deep Dive
Deep Dive{deepDiveMetrics.size > 0 ? ' (' + deepDiveMetrics.size + ')' : ''}
</button>
</nav>
</div>
Expand Down Expand Up @@ -246,11 +255,11 @@ export default function App() {
)}

{view === 'compare' && (
<CompareView ref={compareRef} selected={selected} groupByList={groupByList} setGroupByList={setGroupByList} hiddenFields={hiddenFields} setHiddenFields={setHiddenFields} restoredMetrics={restoredMetrics} />
<CompareView ref={compareRef} selected={selected} groupByList={groupByList} setGroupByList={setGroupByList} hiddenFields={hiddenFields} setHiddenFields={setHiddenFields} restoredMetrics={restoredMetrics} deepDiveMetrics={deepDiveMetrics} setDeepDiveMetrics={setDeepDiveMetrics} />
)}

{view === 'deepdive' && (
<div className="empty-msg">Phase 3: Time-series deep dive coming soon.</div>
<DeepDiveView selected={selected} deepDiveMetrics={deepDiveMetrics} metricConfigs={deepDiveConfigs} />
)}

<DebugConsole />
Expand Down
9 changes: 9 additions & 0 deletions queries/cdmq/web-ui/src/api/cdm.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ export async function getBreakoutValues(params) {
});
}

export async function getPeriodInfo(params) {
return request('POST', '/iterations/period-info', {
iterations: params.iterations,
start: params.start,
end: params.end,
sampleIndex: params.sampleIndex,
});
}

export async function getMetricData(params) {
return request('POST', `/metric-data`, params);
}
Loading
Loading