@sentio/cli 3.6.0-rc.1 → 3.6.0-rc.3

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.
package/lib/index.js CHANGED
@@ -143006,7 +143006,10 @@ function createAlertGetCommand() {
143006
143006
  function createAlertCreateCommand() {
143007
143007
  return withOutputOptions4(
143008
143008
  withSharedProjectOptions4(withAuthOptions4(new Command("create").description("Create an alert rule")))
143009
- ).showHelpAfterError().option("--file <path>", "Read request JSON or YAML from file. Use --doc to show the full alert request format").option("--stdin", "Read request JSON or YAML from stdin. Use --doc to show the full alert request format").option("--doc", "Print the full alert request file format and exit").option("--type <type>", "Alert type: METRIC, LOG, or SQL").option("--subject <text>", "Alert subject/title").option("--message <text>", "Optional alert message template").option("--query <text>", "Inline log query or SQL when not using --file/--stdin").option("--event <name>", "Inline event query for METRIC alerts").option("--metric <name>", "Inline metric query for METRIC alerts").option("--alias <alias>", "Alias for the inline METRIC query").option("--source-name <name>", "Optional source name for the inline METRIC query").option("--filter <selector>", "Inline METRIC query filter like amount>0 or meta.chain=1", collectOption2, []).option("--group-by <field>", "Inline METRIC query group-by field", collectOption2, []).option(
143009
+ ).showHelpAfterError().option("--file <path>", "Read request JSON or YAML from file. Use --doc to show the full alert request format").option("--stdin", "Read request JSON or YAML from stdin. Use --doc to show the full alert request format").option("--doc", "Print the full alert request file format and exit").option("--type <type>", "Alert type: METRIC, LOG, or SQL").option("--subject <text>", "Alert subject/title").option("--message <text>", "Optional alert message template").option(
143010
+ "--query <text>",
143011
+ "Inline query. LOG: Elasticsearch query-string syntax (e.g. amount:>1000, status:error). SQL: full SQL statement."
143012
+ ).option("--event <name>", "Inline event query for METRIC alerts").option("--metric <name>", "Inline metric query for METRIC alerts").option("--alias <alias>", "Alias for the inline METRIC query").option("--source-name <name>", "Optional source name for the inline METRIC query").option("--filter <selector>", "Inline METRIC query filter like amount>0 or meta.chain=1", collectOption2, []).option("--group-by <field>", "Inline METRIC query group-by field", collectOption2, []).option(
143010
143013
  "--aggr <aggregation>",
143011
143014
  "Inline aggregation. METRIC: avg|sum|min|max|count. EVENTS: total|unique|AAU|DAU|WAU|MAU"
143012
143015
  ).option("--func <function>", "Inline function like topk(1), bottomk(1), delta(1m)", collectOption2, []).option("--op <operator>", "Condition operator like >, >=, ==, !=, <, <=, between").option("--threshold <value>", "Condition threshold", parseNumber).option("--threshold2 <value>", "Second threshold for between", parseNumber).option("--for <duration>", "Evaluate over the last duration, for example 5m or 1h").option("--interval <duration>", "Alert evaluation interval, for example 1m or 5m").option("--time-column <column>", "SQL alert time column for column-based conditions").option("--value-column <column>", "SQL alert value column for column-based conditions").option("--sql-aggr <aggregation>", "SQL aggregation: COUNT, SUM, AVG, MAX, MIN, LAST").addHelpText(
@@ -143014,7 +143017,7 @@ function createAlertCreateCommand() {
143014
143017
  `
143015
143018
 
143016
143019
  Examples:
143017
- $ sentio alert create --project sentio/coinbase --type LOG --subject "Large transfer logs" --query 'amount > 1000' --op '>' --threshold 0
143020
+ $ sentio alert create --project sentio/coinbase --type LOG --subject "Large transfer logs" --query 'amount:>1000' --op '>' --threshold 0
143018
143021
  $ sentio alert create --project sentio/coinbase --type SQL --subject "Large transfer(SQL demo)" --query 'select timestamp, amount from transfer where amount > 1000' --time-column timestamp --value-column amount --sql-aggr MAX --op '>' --threshold 1000
143019
143022
  $ sentio alert create --project sentio/coinbase --type METRIC --subject "Burn spike" --metric burn --filter meta.chain=1 --aggr avg --group-by meta.address --op '>' --threshold 100
143020
143023
  $ sentio alert create --project sentio/coinbase --type METRIC --subject "Transfer anomaly" --event Transfer --filter amount>0 --aggr total --func 'delta(1m)' --op '>' --threshold 100
@@ -143637,11 +143640,14 @@ Metric alert example:
143637
143640
  disabled: false
143638
143641
 
143639
143642
  Log alert example:
143643
+ NOTE: logCondition.query uses Elasticsearch query-string syntax.
143644
+ Ranges MUST use field:>value form \u2014 C-style "field > value" is rejected at evaluation time.
143645
+ Examples: amount:>1000000 timestamp:>=2024-01-01 amount:[1000 TO 9999] status:error
143640
143646
  rule:
143641
143647
  alertType: LOG
143642
143648
  subject: large transfer logs
143643
143649
  logCondition:
143644
- query: amount > 1000
143650
+ query: amount:>1000
143645
143651
  comparisonOp: ">"
143646
143652
  threshold: 0
143647
143653
 
@@ -144804,6 +144810,7 @@ import process24 from "process";
144804
144810
  function createDashboardCommand() {
144805
144811
  const dashboardCommand = new Command("dashboard").description("Manage Sentio dashboards");
144806
144812
  dashboardCommand.addCommand(createDashboardListCommand());
144813
+ dashboardCommand.addCommand(createDashboardCreateCommand());
144807
144814
  dashboardCommand.addCommand(createDashboardExportCommand());
144808
144815
  dashboardCommand.addCommand(createDashboardImportCommand());
144809
144816
  dashboardCommand.addCommand(createDashboardAddPanelCommand());
@@ -144820,6 +144827,17 @@ function createDashboardListCommand() {
144820
144827
  }
144821
144828
  });
144822
144829
  }
144830
+ function createDashboardCreateCommand() {
144831
+ return withOutputOptions8(
144832
+ withSharedProjectOptions7(withAuthOptions8(new Command("create").description("Create a dashboard for a project")))
144833
+ ).showHelpAfterError().requiredOption("--title <name>", "Dashboard title").option("--file <path>", "Read initial dashboard JSON or YAML from file").option("--stdin", "Read initial dashboard JSON or YAML from stdin").action(async (options, command) => {
144834
+ try {
144835
+ await runDashboardCreate(options);
144836
+ } catch (error) {
144837
+ handleDashboardCommandError(error, command);
144838
+ }
144839
+ });
144840
+ }
144823
144841
  function createDashboardExportCommand() {
144824
144842
  return withOutputOptions8(
144825
144843
  withSharedProjectOptions7(
@@ -144862,7 +144880,13 @@ function createDashboardAddPanelCommand() {
144862
144880
  "Event filter or metric label selector like field:value or amount>0",
144863
144881
  collectOption3,
144864
144882
  []
144865
- ).option("--group-by <field>", "Group by event property or metric label", collectOption3, []).option("--aggr <aggregation>", "Event: total|unique|AAU|DAU|WAU|MAU. Metric: avg|sum|min|max|count").option("--func <function>", "Function like topk(1), bottomk(1)", collectOption3, []).addHelpText(
144883
+ ).option("--group-by <field>", "Group by event property or metric label", collectOption3, []).option("--aggr <aggregation>", "Event: total|unique|AAU|DAU|WAU|MAU. Metric: avg|sum|min|max|count").option("--func <function>", "Function like topk(1), bottomk(1)", collectOption3, []).option(
144884
+ "--time-range-start <value>",
144885
+ "Panel time range start: relative (e.g. -24h, -7d, -30m) or ISO date (e.g. 2024-01-01T00:00:00Z)"
144886
+ ).option(
144887
+ "--time-range-end <value>",
144888
+ "Panel time range end: relative (e.g. now, -1h) or ISO date. Defaults to now when --time-range-start is set."
144889
+ ).option("--time-range-step <seconds>", "Panel time range step in seconds (e.g. 3600)").addHelpText(
144866
144890
  "after",
144867
144891
  `
144868
144892
 
@@ -144899,7 +144923,17 @@ Metric insights panel examples:
144899
144923
  --metric burn --filter meta.chain=1 --aggr avg --group-by meta.address
144900
144924
  $ sentio dashboard add-panel abc123 --project owner/slug \\
144901
144925
  --panel-name "Burn Rate Delta" --type LINE \\
144902
- --metric burn --aggr sum
144926
+ --metric burn --aggr sum
144927
+
144928
+ Panel time range override examples:
144929
+ $ sentio dashboard add-panel abc123 --project owner/slug \\
144930
+ --panel-name "Last 24h Transfers" --type LINE \\
144931
+ --event Transfer --aggr total \\
144932
+ --time-range-start -24h --time-range-end now --time-range-step 3600
144933
+ $ sentio dashboard add-panel abc123 --project owner/slug \\
144934
+ --panel-name "Jan 2024 Volume" --type BAR \\
144935
+ --sql "SELECT date, sum(amount) FROM Transfer GROUP BY date" \\
144936
+ --time-range-start 2024-01-01T00:00:00Z --time-range-end 2024-02-01T00:00:00Z
144903
144937
  `
144904
144938
  ).action(async (dashboardId, options, command) => {
144905
144939
  try {
@@ -144919,6 +144953,16 @@ async function runDashboardList(options) {
144919
144953
  const data4 = unwrapApiResult(response);
144920
144954
  printOutput8(options, data4);
144921
144955
  }
144956
+ async function runDashboardCreate(options) {
144957
+ const context = createApiContext(options);
144958
+ const project = await resolveProjectRef(options, context, { ownerSlug: true });
144959
+ const body = buildDashboardCreateBody(options, project);
144960
+ const data4 = await postApiJson("/v1/dashboards", context, body);
144961
+ printOutput8(options, {
144962
+ message: `Dashboard "${options.title}" created`,
144963
+ dashboard: data4.dashboard ?? data4
144964
+ });
144965
+ }
144922
144966
  async function runDashboardExport(dashboardId, options) {
144923
144967
  const context = createApiContext(options);
144924
144968
  const response = await import_api14.WebService.exportDashboard({
@@ -144946,6 +144990,40 @@ async function runDashboardImport(dashboardId, options) {
144946
144990
  const data4 = unwrapApiResult(response);
144947
144991
  printOutput8(options, { message: `Dashboard imported into ${dashboardId}`, dashboard: data4.dashboard });
144948
144992
  }
144993
+ function buildDashboardCreateBody(options, project) {
144994
+ const input = loadJsonInput(options);
144995
+ const initialDashboard = normalizeDashboardInit(input);
144996
+ return {
144997
+ name: options.title,
144998
+ projectOwner: project.owner,
144999
+ projectSlug: project.slug,
145000
+ ...initialDashboard
145001
+ };
145002
+ }
145003
+ function normalizeDashboardInit(input) {
145004
+ const emptyLayouts = {
145005
+ responsiveLayouts: {
145006
+ lg: { layouts: [] },
145007
+ md: { layouts: [] },
145008
+ sm: { layouts: [] },
145009
+ xs: { layouts: [] }
145010
+ }
145011
+ };
145012
+ if (input === void 0) {
145013
+ return {
145014
+ panels: {},
145015
+ layouts: emptyLayouts
145016
+ };
145017
+ }
145018
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
145019
+ throw new CliError("Dashboard initialization data must be a JSON or YAML object.");
145020
+ }
145021
+ const dashboard = input;
145022
+ return {
145023
+ panels: isRecord(dashboard.panels) ? dashboard.panels : {},
145024
+ layouts: isRecord(dashboard.layouts) ? dashboard.layouts : emptyLayouts
145025
+ };
145026
+ }
144949
145027
  async function runDashboardAddPanel(dashboardId, options) {
144950
145028
  const context = createApiContext(options);
144951
145029
  const selectedSources = [Boolean(options.sql), Boolean(options.event), Boolean(options.metric)].filter(Boolean).length;
@@ -144967,11 +145045,13 @@ async function runDashboardAddPanel(dashboardId, options) {
144967
145045
  const chartType = normalizeChartType(options.type);
144968
145046
  const panelId = generatePanelId();
144969
145047
  const chart = buildPanelChart(chartType, options);
145048
+ const timeRangeOverride = buildTimeRangeOverride(options);
144970
145049
  const newPanel = {
144971
145050
  id: panelId,
144972
145051
  name: options.panelName,
144973
145052
  dashboardId,
144974
- chart
145053
+ chart,
145054
+ ...timeRangeOverride ? { timeRangeOverride } : {}
144975
145055
  };
144976
145056
  const existingLayouts = dashboard.layouts?.responsiveLayouts?.lg?.layouts ?? [];
144977
145057
  let maxBottom = 0;
@@ -144990,15 +145070,17 @@ async function runDashboardAddPanel(dashboardId, options) {
144990
145070
  };
144991
145071
  const panels = { ...dashboard.panels ?? {} };
144992
145072
  panels[panelId] = newPanel;
144993
- const updatedLayouts = [...existingLayouts, newLayout];
145073
+ const existingResponsive = dashboard.layouts?.responsiveLayouts ?? {};
145074
+ const updatedResponsive = { ...existingResponsive };
145075
+ for (const bp2 of ["lg", "md", "sm", "xs"]) {
145076
+ const existing = existingResponsive[bp2]?.layouts ?? [];
145077
+ updatedResponsive[bp2] = { layouts: [...existing, newLayout] };
145078
+ }
144994
145079
  const dashboardJson = {
144995
145080
  ...dashboard,
144996
145081
  panels,
144997
145082
  layouts: {
144998
- responsiveLayouts: {
144999
- ...dashboard.layouts?.responsiveLayouts ?? {},
145000
- lg: { layouts: updatedLayouts }
145001
- }
145083
+ responsiveLayouts: updatedResponsive
145002
145084
  }
145003
145085
  };
145004
145086
  const importResponse = await import_api14.WebService.importDashboard({
@@ -145016,6 +145098,40 @@ async function runDashboardAddPanel(dashboardId, options) {
145016
145098
  dashboard: importData.dashboard
145017
145099
  });
145018
145100
  }
145101
+ function buildTimeRangeLike(value) {
145102
+ const relMatch = value.match(/^(-?\d+)\s*([smhdwMy])$/);
145103
+ if (relMatch) {
145104
+ return { relativeTime: { unit: relMatch[2], value: Number(relMatch[1]) } };
145105
+ }
145106
+ if (value === "now" || value === "0") {
145107
+ return { relativeTime: { unit: "h", value: 0 } };
145108
+ }
145109
+ const ts2 = Date.parse(value);
145110
+ if (!Number.isNaN(ts2)) {
145111
+ return { absoluteTime: String(ts2) };
145112
+ }
145113
+ throw new CliError(
145114
+ `Invalid time range value "${value}". Use a relative offset (e.g. -24h, -7d, -30m, now) or an ISO date string.`
145115
+ );
145116
+ }
145117
+ function buildTimeRangeOverride(options) {
145118
+ if (!options.timeRangeStart && !options.timeRangeEnd) {
145119
+ return void 0;
145120
+ }
145121
+ const timeRange = {};
145122
+ if (options.timeRangeStart) {
145123
+ timeRange.start = buildTimeRangeLike(options.timeRangeStart);
145124
+ }
145125
+ if (options.timeRangeEnd) {
145126
+ timeRange.end = buildTimeRangeLike(options.timeRangeEnd);
145127
+ } else {
145128
+ timeRange.end = { relativeTime: { unit: "h", value: 0 } };
145129
+ }
145130
+ if (options.timeRangeStep) {
145131
+ timeRange.step = options.timeRangeStep;
145132
+ }
145133
+ return { enabled: true, timeRange };
145134
+ }
145019
145135
  function buildPanelChart(chartType, options) {
145020
145136
  if (options.sql) {
145021
145137
  const sqlSize = Number.parseInt(String(options.size ?? "100"), 10) || 100;
@@ -145072,6 +145188,9 @@ function collectOption3(value, previous = []) {
145072
145188
  previous.push(value);
145073
145189
  return previous;
145074
145190
  }
145191
+ function isRecord(value) {
145192
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
145193
+ }
145075
145194
  function withAuthOptions8(command) {
145076
145195
  return command.option("--host <host>", "Override Sentio host").option("--api-key <key>", "Use an explicit API key instead of saved credentials").option("--token <token>", "Use an explicit bearer token instead of saved credentials");
145077
145196
  }
@@ -145082,7 +145201,7 @@ function withOutputOptions8(command) {
145082
145201
  return command.option("--json", "Print raw JSON response").option("--yaml", "Print raw YAML response");
145083
145202
  }
145084
145203
  function handleDashboardCommandError(error, command) {
145085
- if (error instanceof CliError && (error.message.startsWith("Project is required.") || error.message.startsWith("Invalid project ") || error.message.startsWith("Dashboard ") || error.message.startsWith("Provide --file or --stdin") || error.message.startsWith("Provide exactly one data source") || error.message.startsWith("Use exactly one of --sql") || error.message.startsWith("Invalid chart type") || error.message.startsWith("Invalid aggregation") || error.message.startsWith("Invalid metric aggregation") || error.message.startsWith("Invalid filter") || error.message.startsWith("Invalid metric selector"))) {
145204
+ if (error instanceof CliError && (error.message.startsWith("Project is required.") || error.message.startsWith("Invalid project ") || error.message.startsWith("Dashboard ") || error.message.startsWith("Provide --file or --stdin") || error.message.startsWith("Use either --file or --stdin") || error.message.startsWith("Expected JSON or YAML") || error.message.startsWith("Invalid JSON or YAML") || error.message.startsWith("Dashboard initialization data") || error.message.startsWith("Provide exactly one data source") || error.message.startsWith("Use exactly one of --sql") || error.message.startsWith("Invalid chart type") || error.message.startsWith("Invalid aggregation") || error.message.startsWith("Invalid metric aggregation") || error.message.startsWith("Invalid filter") || error.message.startsWith("Invalid metric selector") || error.message.startsWith("Invalid time range value"))) {
145086
145205
  console.error(error.message);
145087
145206
  if (command) {
145088
145207
  console.error();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sentio/cli",
3
- "version": "3.6.0-rc.1",
3
+ "version": "3.6.0-rc.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -136,7 +136,10 @@ function createAlertCreateCommand() {
136
136
  .option('--type <type>', 'Alert type: METRIC, LOG, or SQL')
137
137
  .option('--subject <text>', 'Alert subject/title')
138
138
  .option('--message <text>', 'Optional alert message template')
139
- .option('--query <text>', 'Inline log query or SQL when not using --file/--stdin')
139
+ .option(
140
+ '--query <text>',
141
+ 'Inline query. LOG: Elasticsearch query-string syntax (e.g. amount:>1000, status:error). SQL: full SQL statement.'
142
+ )
140
143
  .option('--event <name>', 'Inline event query for METRIC alerts')
141
144
  .option('--metric <name>', 'Inline metric query for METRIC alerts')
142
145
  .option('--alias <alias>', 'Alias for the inline METRIC query')
@@ -161,7 +164,7 @@ function createAlertCreateCommand() {
161
164
  `
162
165
 
163
166
  Examples:
164
- $ sentio alert create --project sentio/coinbase --type LOG --subject "Large transfer logs" --query 'amount > 1000' --op '>' --threshold 0
167
+ $ sentio alert create --project sentio/coinbase --type LOG --subject "Large transfer logs" --query 'amount:>1000' --op '>' --threshold 0
165
168
  $ sentio alert create --project sentio/coinbase --type SQL --subject "Large transfer(SQL demo)" --query 'select timestamp, amount from transfer where amount > 1000' --time-column timestamp --value-column amount --sql-aggr MAX --op '>' --threshold 1000
166
169
  $ sentio alert create --project sentio/coinbase --type METRIC --subject "Burn spike" --metric burn --filter meta.chain=1 --aggr avg --group-by meta.address --op '>' --threshold 100
167
170
  $ sentio alert create --project sentio/coinbase --type METRIC --subject "Transfer anomaly" --event Transfer --filter amount>0 --aggr total --func 'delta(1m)' --op '>' --threshold 100
@@ -916,11 +919,14 @@ Metric alert example:
916
919
  disabled: false
917
920
 
918
921
  Log alert example:
922
+ NOTE: logCondition.query uses Elasticsearch query-string syntax.
923
+ Ranges MUST use field:>value form — C-style "field > value" is rejected at evaluation time.
924
+ Examples: amount:>1000000 timestamp:>=2024-01-01 amount:[1000 TO 9999] status:error
919
925
  rule:
920
926
  alertType: LOG
921
927
  subject: large transfer logs
922
928
  logCondition:
923
- query: amount > 1000
929
+ query: amount:>1000
924
930
  comparisonOp: ">"
925
931
  threshold: 0
926
932
 
@@ -7,6 +7,7 @@ import {
7
7
  createApiContext,
8
8
  handleCommandError,
9
9
  loadJsonInput,
10
+ postApiJson,
10
11
  resolveProjectRef,
11
12
  unwrapApiResult
12
13
  } from '../api.js'
@@ -30,6 +31,12 @@ interface DashboardImportOptions extends DashboardOptions {
30
31
  overrideLayouts?: boolean
31
32
  }
32
33
 
34
+ interface DashboardCreateOptions extends DashboardOptions {
35
+ title?: string
36
+ file?: string
37
+ stdin?: boolean
38
+ }
39
+
33
40
  interface AddPanelOptions extends DashboardOptions {
34
41
  panelName?: string
35
42
  type?: string
@@ -43,11 +50,15 @@ interface AddPanelOptions extends DashboardOptions {
43
50
  groupBy?: string[]
44
51
  aggr?: string
45
52
  func?: string[]
53
+ timeRangeStart?: string
54
+ timeRangeEnd?: string
55
+ timeRangeStep?: string
46
56
  }
47
57
 
48
58
  export function createDashboardCommand() {
49
59
  const dashboardCommand = new Command('dashboard').description('Manage Sentio dashboards')
50
60
  dashboardCommand.addCommand(createDashboardListCommand())
61
+ dashboardCommand.addCommand(createDashboardCreateCommand())
51
62
  dashboardCommand.addCommand(createDashboardExportCommand())
52
63
  dashboardCommand.addCommand(createDashboardImportCommand())
53
64
  dashboardCommand.addCommand(createDashboardAddPanelCommand())
@@ -68,6 +79,23 @@ function createDashboardListCommand() {
68
79
  })
69
80
  }
70
81
 
82
+ function createDashboardCreateCommand() {
83
+ return withOutputOptions(
84
+ withSharedProjectOptions(withAuthOptions(new Command('create').description('Create a dashboard for a project')))
85
+ )
86
+ .showHelpAfterError()
87
+ .requiredOption('--title <name>', 'Dashboard title')
88
+ .option('--file <path>', 'Read initial dashboard JSON or YAML from file')
89
+ .option('--stdin', 'Read initial dashboard JSON or YAML from stdin')
90
+ .action(async (options, command) => {
91
+ try {
92
+ await runDashboardCreate(options)
93
+ } catch (error) {
94
+ handleDashboardCommandError(error, command)
95
+ }
96
+ })
97
+ }
98
+
71
99
  function createDashboardExportCommand() {
72
100
  return withOutputOptions(
73
101
  withSharedProjectOptions(
@@ -137,6 +165,15 @@ function createDashboardAddPanelCommand() {
137
165
  .option('--group-by <field>', 'Group by event property or metric label', collectOption, [])
138
166
  .option('--aggr <aggregation>', 'Event: total|unique|AAU|DAU|WAU|MAU. Metric: avg|sum|min|max|count')
139
167
  .option('--func <function>', 'Function like topk(1), bottomk(1)', collectOption, [])
168
+ .option(
169
+ '--time-range-start <value>',
170
+ 'Panel time range start: relative (e.g. -24h, -7d, -30m) or ISO date (e.g. 2024-01-01T00:00:00Z)'
171
+ )
172
+ .option(
173
+ '--time-range-end <value>',
174
+ 'Panel time range end: relative (e.g. now, -1h) or ISO date. Defaults to now when --time-range-start is set.'
175
+ )
176
+ .option('--time-range-step <seconds>', 'Panel time range step in seconds (e.g. 3600)')
140
177
  .addHelpText(
141
178
  'after',
142
179
  `
@@ -174,7 +211,17 @@ Metric insights panel examples:
174
211
  --metric burn --filter meta.chain=1 --aggr avg --group-by meta.address
175
212
  $ sentio dashboard add-panel abc123 --project owner/slug \\
176
213
  --panel-name "Burn Rate Delta" --type LINE \\
177
- --metric burn --aggr sum
214
+ --metric burn --aggr sum
215
+
216
+ Panel time range override examples:
217
+ $ sentio dashboard add-panel abc123 --project owner/slug \\
218
+ --panel-name "Last 24h Transfers" --type LINE \\
219
+ --event Transfer --aggr total \\
220
+ --time-range-start -24h --time-range-end now --time-range-step 3600
221
+ $ sentio dashboard add-panel abc123 --project owner/slug \\
222
+ --panel-name "Jan 2024 Volume" --type BAR \\
223
+ --sql "SELECT date, sum(amount) FROM Transfer GROUP BY date" \\
224
+ --time-range-start 2024-01-01T00:00:00Z --time-range-end 2024-02-01T00:00:00Z
178
225
  `
179
226
  )
180
227
  .action(async (dashboardId, options, command) => {
@@ -197,6 +244,18 @@ async function runDashboardList(options: DashboardOptions) {
197
244
  printOutput(options, data)
198
245
  }
199
246
 
247
+ async function runDashboardCreate(options: DashboardCreateOptions) {
248
+ const context = createApiContext(options)
249
+ const project = await resolveProjectRef(options, context, { ownerSlug: true })
250
+ const body = buildDashboardCreateBody(options, project)
251
+ const data = await postApiJson<{ dashboard?: Record<string, unknown> }>('/v1/dashboards', context, body)
252
+
253
+ printOutput(options, {
254
+ message: `Dashboard "${options.title}" created`,
255
+ dashboard: data.dashboard ?? data
256
+ })
257
+ }
258
+
200
259
  async function runDashboardExport(dashboardId: string, options: DashboardOptions) {
201
260
  const context = createApiContext(options)
202
261
  const response = await WebService.exportDashboard({
@@ -229,6 +288,46 @@ async function runDashboardImport(dashboardId: string, options: DashboardImportO
229
288
  printOutput(options, { message: `Dashboard imported into ${dashboardId}`, dashboard: data.dashboard })
230
289
  }
231
290
 
291
+ function buildDashboardCreateBody(options: DashboardCreateOptions, project: { owner: string; slug: string }) {
292
+ const input = loadJsonInput(options)
293
+ const initialDashboard = normalizeDashboardInit(input)
294
+
295
+ return {
296
+ name: options.title,
297
+ projectOwner: project.owner,
298
+ projectSlug: project.slug,
299
+ ...initialDashboard
300
+ }
301
+ }
302
+
303
+ function normalizeDashboardInit(input: unknown) {
304
+ const emptyLayouts = {
305
+ responsiveLayouts: {
306
+ lg: { layouts: [] },
307
+ md: { layouts: [] },
308
+ sm: { layouts: [] },
309
+ xs: { layouts: [] }
310
+ }
311
+ }
312
+
313
+ if (input === undefined) {
314
+ return {
315
+ panels: {},
316
+ layouts: emptyLayouts
317
+ }
318
+ }
319
+
320
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
321
+ throw new CliError('Dashboard initialization data must be a JSON or YAML object.')
322
+ }
323
+
324
+ const dashboard = input as Record<string, unknown>
325
+ return {
326
+ panels: isRecord(dashboard.panels) ? dashboard.panels : {},
327
+ layouts: isRecord(dashboard.layouts) ? dashboard.layouts : emptyLayouts
328
+ }
329
+ }
330
+
232
331
  async function runDashboardAddPanel(dashboardId: string, options: AddPanelOptions) {
233
332
  const context = createApiContext(options)
234
333
 
@@ -257,11 +356,13 @@ async function runDashboardAddPanel(dashboardId: string, options: AddPanelOption
257
356
  const panelId = generatePanelId()
258
357
  const chart = buildPanelChart(chartType, options)
259
358
 
260
- const newPanel = {
359
+ const timeRangeOverride = buildTimeRangeOverride(options)
360
+ const newPanel: Record<string, unknown> = {
261
361
  id: panelId,
262
362
  name: options.panelName,
263
363
  dashboardId,
264
- chart
364
+ chart,
365
+ ...(timeRangeOverride ? { timeRangeOverride } : {})
265
366
  }
266
367
 
267
368
  // 3. Compute layout position: place below all existing panels
@@ -286,16 +387,18 @@ async function runDashboardAddPanel(dashboardId: string, options: AddPanelOption
286
387
  const panels = { ...(dashboard.panels ?? {}) }
287
388
  panels[panelId] = newPanel as never
288
389
 
289
- const updatedLayouts = [...existingLayouts, newLayout]
390
+ const existingResponsive = dashboard.layouts?.responsiveLayouts ?? {}
391
+ const updatedResponsive: Record<string, unknown> = { ...existingResponsive }
392
+ for (const bp of ['lg', 'md', 'sm', 'xs'] as const) {
393
+ const existing = (existingResponsive as Record<string, { layouts?: unknown[] } | undefined>)[bp]?.layouts ?? []
394
+ updatedResponsive[bp] = { layouts: [...existing, newLayout] }
395
+ }
290
396
 
291
397
  const dashboardJson: Record<string, unknown> = {
292
398
  ...dashboard,
293
399
  panels,
294
400
  layouts: {
295
- responsiveLayouts: {
296
- ...(dashboard.layouts?.responsiveLayouts ?? {}),
297
- lg: { layouts: updatedLayouts }
298
- }
401
+ responsiveLayouts: updatedResponsive
299
402
  }
300
403
  }
301
404
 
@@ -315,6 +418,43 @@ async function runDashboardAddPanel(dashboardId: string, options: AddPanelOption
315
418
  })
316
419
  }
317
420
 
421
+ function buildTimeRangeLike(value: string): Record<string, unknown> {
422
+ const relMatch = value.match(/^(-?\d+)\s*([smhdwMy])$/)
423
+ if (relMatch) {
424
+ return { relativeTime: { unit: relMatch[2], value: Number(relMatch[1]) } }
425
+ }
426
+ if (value === 'now' || value === '0') {
427
+ return { relativeTime: { unit: 'h', value: 0 } }
428
+ }
429
+ const ts = Date.parse(value)
430
+ if (!Number.isNaN(ts)) {
431
+ return { absoluteTime: String(ts) }
432
+ }
433
+ throw new CliError(
434
+ `Invalid time range value "${value}". Use a relative offset (e.g. -24h, -7d, -30m, now) or an ISO date string.`
435
+ )
436
+ }
437
+
438
+ function buildTimeRangeOverride(options: AddPanelOptions): Record<string, unknown> | undefined {
439
+ if (!options.timeRangeStart && !options.timeRangeEnd) {
440
+ return undefined
441
+ }
442
+ const timeRange: Record<string, unknown> = {}
443
+ if (options.timeRangeStart) {
444
+ timeRange.start = buildTimeRangeLike(options.timeRangeStart)
445
+ }
446
+ if (options.timeRangeEnd) {
447
+ timeRange.end = buildTimeRangeLike(options.timeRangeEnd)
448
+ } else {
449
+ // default end to "now" when start is provided
450
+ timeRange.end = { relativeTime: { unit: 'h', value: 0 } }
451
+ }
452
+ if (options.timeRangeStep) {
453
+ timeRange.step = options.timeRangeStep
454
+ }
455
+ return { enabled: true, timeRange }
456
+ }
457
+
318
458
  function buildPanelChart(chartType: string, options: AddPanelOptions) {
319
459
  if (options.sql) {
320
460
  const sqlSize = Number.parseInt(String(options.size ?? '100'), 10) || 100
@@ -378,6 +518,10 @@ function collectOption(value: string, previous: string[] = []) {
378
518
  return previous
379
519
  }
380
520
 
521
+ function isRecord(value: unknown): value is Record<string, unknown> {
522
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
523
+ }
524
+
381
525
  function withAuthOptions<T extends Command<any, any, any>>(command: T) {
382
526
  return command
383
527
  .option('--host <host>', 'Override Sentio host')
@@ -404,13 +548,18 @@ function handleDashboardCommandError(error: unknown, command?: Command) {
404
548
  error.message.startsWith('Invalid project ') ||
405
549
  error.message.startsWith('Dashboard ') ||
406
550
  error.message.startsWith('Provide --file or --stdin') ||
551
+ error.message.startsWith('Use either --file or --stdin') ||
552
+ error.message.startsWith('Expected JSON or YAML') ||
553
+ error.message.startsWith('Invalid JSON or YAML') ||
554
+ error.message.startsWith('Dashboard initialization data') ||
407
555
  error.message.startsWith('Provide exactly one data source') ||
408
556
  error.message.startsWith('Use exactly one of --sql') ||
409
557
  error.message.startsWith('Invalid chart type') ||
410
558
  error.message.startsWith('Invalid aggregation') ||
411
559
  error.message.startsWith('Invalid metric aggregation') ||
412
560
  error.message.startsWith('Invalid filter') ||
413
- error.message.startsWith('Invalid metric selector'))
561
+ error.message.startsWith('Invalid metric selector') ||
562
+ error.message.startsWith('Invalid time range value'))
414
563
  ) {
415
564
  console.error(error.message)
416
565
  if (command) {