Skip to content
Open
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
16 changes: 14 additions & 2 deletions backend/actions/Dashboard/createDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const Archetype = require('archetype');
const authorize = require('../../authorize');
const mongoose = require('mongoose');

const CreateDashboardParams = new Archetype({
title: {
Expand All @@ -12,18 +13,29 @@
$type: 'string',
$required: true
},
initiatedById: {
$type: mongoose.Types.ObjectId,
$required: false
},
roles: {
$type: ['string']
}
}).compile('CreateDashboardParams');

module.exports = ({ studioConnection }) => async function createDashboard(params) {
const { title, code, roles } = new CreateDashboardParams(params);
const { title, code, initiatedById, roles } = new CreateDashboardParams(params);
const Dashboard = studioConnection.model('__Studio_Dashboard');

await authorize('Dashboard.createDashboard', roles);

const dashboard = await Dashboard.create({ title, code });
console.log('Test', params)

Check failure on line 31 in backend/actions/Dashboard/createDashboard.js

View workflow job for this annotation

GitHub Actions / lint

Missing semicolon

Check failure on line 31 in backend/actions/Dashboard/createDashboard.js

View workflow job for this annotation

GitHub Actions / lint

Missing semicolon

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove creation parameter logging

Every dashboard creation now logs the full params object, which includes the dashboard code and the authenticated user's details when running with an API key. In production this can leak proprietary dashboard scripts or user PII into server logs, and the log line appears unrelated to the returned result.

Useful? React with 👍 / 👎.


const dashboard = await Dashboard.create({
title,
code,
createdById: initiatedById,
createdBy: params.initiatedBy
});
Comment on lines +31 to +38

return { dashboard };
};
8 changes: 6 additions & 2 deletions backend/actions/Dashboard/getDashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ module.exports = ({ db, studioConnection, options }) => async function getDashbo
return {};
}
return completeDashboardEvaluate(
Dashboard,
DashboardResult,
dashboardResult._id,
null,
Expand All @@ -73,6 +74,7 @@ module.exports = ({ db, studioConnection, options }) => async function getDashbo
return {};
}
return completeDashboardEvaluate(
Dashboard,
DashboardResult,
dashboardResult._id,
result,
Expand All @@ -97,13 +99,15 @@ module.exports = ({ db, studioConnection, options }) => async function getDashbo
}
};

async function completeDashboardEvaluate(DashboardResult, dashboardResultId, result, error, status) {
async function completeDashboardEvaluate(Dashboard, DashboardResult, dashboardResultId, result, error, status) {
const dashboardResult = await DashboardResult.findById(dashboardResultId).orFail();
dashboardResult.finishedEvaluatingAt = new Date();
const finishedEvaluatingAt = new Date();
dashboardResult.finishedEvaluatingAt = finishedEvaluatingAt;
dashboardResult.result = result;
dashboardResult.error = error;
dashboardResult.status = status;
await dashboardResult.save();
await Dashboard.updateOne({ _id: dashboardResult.dashboardId }, { $set: { lastEvaluatedAt: finishedEvaluatingAt } });
return { dashboardResult };
}

Expand Down
2 changes: 1 addition & 1 deletion backend/actions/Dashboard/getDashboards.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ module.exports = ({ studioConnection }) => async function getDashboards(params)

await authorize('Dashboard.getDashboards', roles);

const dashboards = await Dashboard.find();
const dashboards = await Dashboard.find().sort({ createdAt: -1, _id: -1 }).lean();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sort legacy dashboards after deriving createdAt

For legacy dashboards that do not have createdAt, the schema derives and persists createdAt only in the post-find hook, but this MongoDB sort has already happened by then. In a mixed collection, a recently created legacy dashboard with no createdAt sorts below every dashboard that has any createdAt value, so the dashboard list is not actually newest-first until after that document has been backfilled by a prior request.

Useful? React with 👍 / 👎.


return { dashboards };
};
24 changes: 24 additions & 0 deletions backend/db/dashboardSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,30 @@ const dashboardSchema = new mongoose.Schema({
},
description: {
type: String
},
createdById: {
type: mongoose.Schema.Types.ObjectId
},
createdBy: {
name: String,
email: String
},
lastEvaluatedAt: {
type: Date
}
}, { timestamps: true });

dashboardSchema.post(['find', 'findOne'], async function(docs) {
const dashboards = Array.isArray(docs) ? docs : [docs];
for (const dashboard of dashboards) {
if (dashboard != null && dashboard.createdAt == null && dashboard._id?.getTimestamp) {
dashboard.createdAt = dashboard._id.getTimestamp();
await this.model.db.model('__Studio_Dashboard').updateOne(
{ _id: dashboard._id },
{ createdAt: dashboard._id.getTimestamp() },
{ overwriteImmutable: true }
);
Comment on lines +34 to +38
}
}
});

Expand Down
1 change: 1 addition & 0 deletions backend/netlify.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ module.exports = function netlify(conn, options) {
params.roles = roles;
params.userId = user._id;
params.initiatedById = user._id;
params.initiatedBy = user;
}

if (typeof actionName !== 'string') {
Expand Down
1 change: 1 addition & 0 deletions backend/next.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ module.exports = function next(conn, options) {
params.roles = roles;
params.userId = user._id;
params.initiatedById = user._id;
params.initiatedBy = user;
}

if (typeof actionName !== 'string') {
Expand Down
1 change: 1 addition & 0 deletions express.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ module.exports = async function mongooseStudioExpressApp(apiUrl, conn, options)
req._internals.initiatedById = user._id;
req._internals.roles = roles;
req._internals.$workspaceId = workspace._id;
req._internals.initiatedBy = user;

next();
})
Expand Down
47 changes: 43 additions & 4 deletions frontend/src/dashboards/dashboards.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ <h1 class="text-lg font-semibold text-content">Dashboards</h1>
</button>
</div>

<div v-if="status !== 'loading'" class="mb-5">
<label for="dashboard-search" class="sr-only">Search dashboards</label>
<div class="relative max-w-lg">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-content-tertiary">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
</div>
<input
id="dashboard-search"
v-model="searchText"
type="search"
class="block w-full rounded-md border-0 bg-surface py-2 pl-9 pr-3 text-sm text-content shadow-sm ring-1 ring-inset ring-edge placeholder:text-content-tertiary focus:ring-2 focus:ring-inset focus:ring-primary"
placeholder="Search by title or description">
</div>
</div>

<div v-if="status === 'loading'" class="text-center py-12">
<svg
class="inline w-8 h-8 animate-spin text-gray-400"
Expand Down Expand Up @@ -38,19 +55,41 @@ <h3 class="text-sm font-semibold text-content">No dashboards yet</h3>
</div>
</div>

<div v-else-if="filteredDashboards.length === 0" class="text-center py-12">
<h3 class="text-sm font-semibold text-content">No matching dashboards</h3>
<p class="mt-1 text-sm text-content-tertiary">Try a different title or description.</p>
</div>

<div v-else class="space-y-3">
<div
v-for="dashboard in dashboards"
v-for="dashboard in filteredDashboards"
:key="dashboard._id"
class="bg-surface rounded-lg border border-edge px-5 py-4 flex items-center justify-between hover:border-edge-strong transition-colors"
class="bg-surface rounded-lg border border-edge px-5 py-4 flex items-start justify-between hover:border-edge-strong transition-colors"
>
<div class="min-w-0 flex-1 mr-4">
<router-link
:to="'/dashboard/' + dashboard._id"
class="text-sm font-semibold text-content hover:text-primary block truncate">
{{ dashboard.title }}
<span v-html="highlightTitle(dashboard.title)"></span>
</router-link>
<p v-if="dashboard.description" class="mt-0.5 text-sm text-content-tertiary truncate">{{ dashboard.description }}</p>
<p
v-if="dashboard.description"
class="mt-0.5 text-sm text-content-tertiary truncate"
v-html="highlightDescription(dashboard.description)"></p>
<dl class="mt-3 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-content-tertiary sm:grid-cols-3">
<div>
<dt class="font-medium text-content-secondary">Created At</dt>
<dd>{{ formatDate(dashboard.createdAt) }}</dd>
</div>
<div>
<dt class="font-medium text-content-secondary">Last Evaluated At</dt>
<dd>{{ formatDate(dashboard.lastEvaluatedAt) }}</dd>
</div>
<div v-show="dashboard.createdBy">
<dt class="font-medium text-content-secondary">Created By</dt>
<dd class="truncate">{{ dashboard.createdBy?.name }}</dd>
</div>
Comment on lines +88 to +91
</dl>
</div>
<div class="flex items-center gap-2 shrink-0">
<router-link
Expand Down
73 changes: 72 additions & 1 deletion frontend/src/dashboards/dashboards.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,69 @@ module.exports = app => app.component('dashboards', {
data: () => ({
status: 'loading',
dashboards: [],
searchText: '',
showCreateDashboardModal: false,
showDeleteDashboardModal: null,
openMenuId: null
}),
computed: {
filteredDashboards() {
const searchText = this.searchText.trim().toLowerCase();
if (!searchText) {
return this.dashboards;
}

return this.dashboards.filter(dashboard => {
return (dashboard.title || '').toLowerCase().includes(searchText) ||
(dashboard.description || '').toLowerCase().includes(searchText);
});
}
Comment on lines +18 to +28
},
methods: {
highlightTitle(value) {
return this.highlightSearchText(value, 'underline decoration-primary underline-offset-2');
},
highlightDescription(value) {
return this.highlightSearchText(value, 'font-semibold text-content-secondary');
},
highlightSearchText(value, className) {
const text = value || '';
const searchText = this.searchText.trim();
if (!searchText) {
return escapeHtml(text);
}

const pattern = new RegExp(escapeRegExp(searchText), 'ig');
let html = '';
let lastIndex = 0;
let match = pattern.exec(text);
while (match != null) {
html += escapeHtml(text.slice(lastIndex, match.index));
html += `<span class="${className}">${escapeHtml(match[0])}</span>`;
lastIndex = match.index + match[0].length;
match = pattern.exec(text);
}
html += escapeHtml(text.slice(lastIndex));
return html;
},
formatDate(value) {
if (!value) {
return '-';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '-';
}
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date);
},
formatUser(dashboard) {
return dashboard.createdBy?.name ||
dashboard.createdBy?.email ||
(dashboard.createdById ? dashboard.createdById.toString() : '-');
},
toggleMenu(id) {
this.openMenuId = this.openMenuId === id ? null : id;
},
Expand All @@ -33,7 +91,7 @@ module.exports = app => app.component('dashboards', {
this.$toast.success('Dashboard deleted!');
},
insertNewDashboard(dashboard) {
this.dashboards.push(dashboard);
this.dashboards.unshift(dashboard);
this.showCreateDashboardModal = false;
}
},
Expand All @@ -58,3 +116,16 @@ module.exports = app => app.component('dashboards', {
this.status = 'loaded';
}
});

function escapeHtml(value) {
return value.toString().
replace(/&/g, '&amp;').
replace(/</g, '&lt;').
replace(/>/g, '&gt;').
replace(/"/g, '&quot;').
replace(/'/g, '&#39;');
}

function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
6 changes: 6 additions & 0 deletions test/Dashboard.getDashboard.results.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ describe('Dashboard.getDashboard() results', function () {
assert.ok(persisted);
assert.strictEqual(persisted.status, 'completed');
assert.deepStrictEqual(persisted.result, { total: 42 });

const persistedDashboard = await Dashboard.findById(dashboard._id).lean();
assert.strictEqual(
persistedDashboard.lastEvaluatedAt.toISOString(),
persisted.finishedEvaluatingAt.toISOString()
);
});

it('returns persisted results from studioConnection', async function () {
Expand Down
79 changes: 79 additions & 0 deletions test/Dashboard.getDashboards.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use strict';

const assert = require('assert');
const mongoose = require('mongoose');
const { actions, studioConnection } = require('./setup.test');
const dashboardSchema = require('../backend/db/dashboardSchema');

const Dashboard = studioConnection.model('__Studio_Dashboard', dashboardSchema, 'studio__dashboards');

describe('Dashboard.getDashboards()', function() {
afterEach(async function() {
await Dashboard.deleteMany();
});

it('returns newest dashboards first with stored evaluation time', async function() {
const older = await Dashboard.create({
title: 'Older',
description: 'First dashboard',
code: 'return 1;',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z')
});
const newer = await Dashboard.create({
title: 'Newer',
description: 'Second dashboard',
code: 'return 2;',
createdAt: new Date('2026-01-02T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
lastEvaluatedAt: new Date('2026-01-04T00:00:01.000Z')
});

const res = await actions.Dashboard.getDashboards({ roles: ['dashboards'] });

assert.deepStrictEqual(res.dashboards.map(dashboard => dashboard.title), ['Newer', 'Older']);
assert.strictEqual(
new Date(res.dashboards[0].lastEvaluatedAt).toISOString(),
'2026-01-04T00:00:01.000Z'
);
assert.strictEqual(res.dashboards[1].lastEvaluatedAt, undefined);
assert.strictEqual(older.title, 'Older');
});

it('derives createdAt from _id for legacy dashboards', async function() {
const dashboard = await Dashboard.create({
title: 'Legacy',
code: 'return 1;'
});
await Dashboard.collection.updateOne({ _id: dashboard._id }, { $unset: { createdAt: 1 } });

const res = await actions.Dashboard.getDashboards({ roles: ['dashboards'] });

assert.strictEqual(res.dashboards.length, 1);
assert.strictEqual(
new Date(res.dashboards[0].createdAt).toISOString(),
dashboard._id.getTimestamp().toISOString()
);
});

it('stores the creating user id when creating a dashboard', async function() {
const userId = new mongoose.Types.ObjectId();

const res = await actions.Dashboard.createDashboard({
title: 'Created By Test',
code: 'return 1;',
initiatedById: userId,
initiatedBy: {
name: 'Jane Doe',
email: 'jane@example.com'
},
roles: ['member']
});

assert.strictEqual(res.dashboard.createdById.toString(), userId.toString());
assert.deepStrictEqual(res.dashboard.createdBy.toObject(), {
name: 'Jane Doe',
email: 'jane@example.com'
});
});
});
Loading