From acf175b84aa398c4bd45a99fbee49def8d645721 Mon Sep 17 00:00:00 2001 From: Mickael Gaillard Date: Thu, 16 Apr 2026 17:35:19 +0200 Subject: [PATCH 1/4] fix(auth): handle invalid/rotated Fastly token with user-friendly message When the Fastly Service Token is rotated or becomes invalid, the plugin now shows a clear warning banner instead of silently redirecting to the setup screen. Fixes two cases: - 401 response from API: detected in refresh().catch(), logout reason stored in sessionStorage before credentials are cleared - Token empty after decryption (corrupted/migrated): detected directly in loadCredentials() The logout reason is stored in sessionStorage (survives page reload, cleared on tab close) and displayed as a warning Message in SetupCard when the component mounts. Also removes dead code: CREDENTIALS_INVALID eventBus event and its listeners, cleans up AppTopbar (logout button was not displayed due to topbar being commented out in AppLayout). Closes #36 --- src/components/SetupCard.vue | 15 +++++++++++++- src/layout/AppTopbar.vue | 16 +-------------- src/services/secureApiService.ts | 8 ++++++++ src/stores/credentialsStore.ts | 35 +++++++++++++++++++++++++++++++- src/stores/localStorage.ts | 15 ++++++++++++++ src/views/HomeView.vue | 28 ++++++++++++++++++++++++- 6 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/components/SetupCard.vue b/src/components/SetupCard.vue index 45225cb..e4cc48a 100644 --- a/src/components/SetupCard.vue +++ b/src/components/SetupCard.vue @@ -3,9 +3,15 @@ import { ref } from 'vue'; import Card from 'primevue/card'; import Button from 'primevue/button'; import InputText from 'primevue/inputtext'; +import Message from 'primevue/message'; import ApiCache from '@/stores/localStorage'; const apiStorage = new ApiCache(); +// Read directly from sessionStorage at setup time — synchronous, no timing issues. +// sessionStorage is written by setLogoutReason() before logout() clears credentials, +// so it is always available when this component mounts after a 401. +const logoutReason = ref(apiStorage.getLogoutReason()); +console.log('FastSun > SetupCard mounted, logoutReason:', logoutReason.value); const props = defineProps({ service_id: { type: String, @@ -43,6 +49,8 @@ function saveId() { if (fastly_id.value && fastly_token.value) { console.log('FastSun > Set ID & Token for project:', props.project_id, 'environment:', props.environment_id); apiStorage.setFastlyCredentials(props.project_id, props.environment_id, fastly_id.value, fastly_token.value); + apiStorage.clearLogoutReason(); + logoutReason.value = ''; // Emit the saved credentials to parent component emit('credentials:saved', { @@ -60,8 +68,13 @@ function saveId() { From 29814ee14bddd102914b846d621f4b753608a98a Mon Sep 17 00:00:00 2001 From: Vincent ROBERT Date: Tue, 21 Jul 2026 22:33:18 +0200 Subject: [PATCH 2/4] feat(history): chart-layer support for the new analytics views Extend the historical chart utilities used by the History tab: - createHistoricalChartOptions gains stacked / valueSuffix / countAxisTitle params and a secondary right-hand count axis (y_cnt2); both right axes use display:'auto' so each shows only when it has a visible series. - Enable drag-to-zoom support (mousedown/mouseup in the event set), a value-suffix-aware tooltip, and a legend "solo" onClick that isolates the clicked series (guarded against the mouseup+click double-fire). - New dataset factories: status codes (1xx-5xx), per-class status drill-downs, and bandwidth (edge/origin), plus a datasetOverrides hook used to hide always-on point markers. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/chartTools.ts | 198 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 184 insertions(+), 14 deletions(-) diff --git a/src/utils/chartTools.ts b/src/utils/chartTools.ts index ef84831..3c50c9c 100644 --- a/src/utils/chartTools.ts +++ b/src/utils/chartTools.ts @@ -172,13 +172,49 @@ export const createRealtimeChartOptions = () => ({ /** * Chart configuration options for historical charts (days/months/years resolution) * Defines scales, tooltips, legends, and other chart behaviors for historical data - * @param {string} timeUnit - The time unit for the x-axis ('day', 'month', 'year') + * @param {string} timeUnit - The time unit for the x-axis ('minute', 'hour', 'day', 'month', 'year') * @param {number} stepSize - The step size for the time axis +/** + * Legend click handler that isolates ("solos") the clicked series so only it + * is shown and the axis rescales to it. Clicking the already-soloed series + * restores every dataset to its default visibility. */ -export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: number = 1) => ({ +/* eslint-disable @typescript-eslint/no-explicit-any */ +function soloLegendClick(e: any, legendItem: any, legend: any) { + // The chart's events include 'mouseup' (for drag-to-zoom), and Chart.js fires + // the legend onClick on BOTH 'mouseup' and 'click'. Act only on the real + // click, otherwise the two fires cancel out this toggle. + if (e && e.type === 'mouseup') return; + const chart = legend.chart; + const index = legendItem.datasetIndex; + const datasets = chart.data.datasets as any[]; + const visibleCount = datasets.reduce((n: number, _ds: any, i: number) => n + (chart.isDatasetVisible(i) ? 1 : 0), 0); + const isSoloed = visibleCount === 1 && chart.isDatasetVisible(index); + datasets.forEach((ds: any, i: number) => { + // Restore each dataset's default visibility when un-soloing, else show only the clicked one. + chart.setDatasetVisibility(i, isSoloed ? !ds.hidden : i === index); + }); + chart.update(); +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +/** + * @param {string} axisTitle - The label for the x-axis (e.g. 'Time (UTC)') + * @param {boolean} stacked - Whether to stack the count axis / x categories (status-code view) + */ +export const createHistoricalChartOptions = ( + timeUnit: string = 'day', + stepSize: number = 1, + axisTitle: string = 'Time', + stacked: boolean = false, + valueSuffix: string = '', + countAxisTitle: string = 'Count', +) => ({ responsive: true, maintainAspectRatio: false, animation: false, + // mousedown/mouseup are needed by the drag-to-zoom plugin (not in Chart.js defaults). + events: ['mousemove', 'mouseout', 'click', 'mousedown', 'mouseup', 'touchstart', 'touchmove', 'touchend'], interaction: { mode: 'nearest', intersect: true, @@ -187,6 +223,8 @@ export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: legend: { display: true, position: 'right', + // Click a series to show only it (axis rescales); click again to restore all. + onClick: soloLegendClick, labels: { boxWidth: 12, padding: 10, @@ -225,7 +263,10 @@ export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: } else { formattedValue = value.toLocaleString(); } - return `${datasetLabel}: ${formattedValue}`; + // Only the primary count axis carries the value suffix (e.g. bytes); + // the secondary axis (y_cnt2) is a plain count (origin requests). + const suffix = context.dataset.yAxisID === 'y_cnt' ? valueSuffix : ''; + return `${datasetLabel}: ${formattedValue}${suffix}`; } }, }, @@ -239,6 +280,7 @@ export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: display: true, position: 'left', min: 0, + stacked, ticks: { /** * Custom tick label formatter for count axis @@ -247,25 +289,28 @@ export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: */ callback: function (value: number) { // Format with unit prefixes (k, M, G) and thousands separators + let formatted; if (value >= 1000000000) { - return (value / 1000000000).toFixed(1).replace(/\.0$/, '') + 'G'; + formatted = (value / 1000000000).toFixed(1).replace(/\.0$/, '') + 'G'; } else if (value >= 1000000) { - return (value / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + formatted = (value / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; } else if (value >= 1000) { - return (value / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + formatted = (value / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; } else { - return value.toLocaleString(); + formatted = value.toLocaleString(); } + return formatted + valueSuffix; }, }, title: { display: true, - text: 'Count', + text: countAxisTitle, }, }, y_per: { type: 'linear', - display: true, + // display 'auto' -> only shown when a dataset assigned to it is visible. + display: 'auto', position: 'right', min: 0, max: 100, @@ -287,8 +332,31 @@ export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: drawOnChartArea: false, }, }, + // Secondary right-hand count axis (e.g. origin request count on the bandwidth view). + y_cnt2: { + type: 'linear', + display: 'auto', + position: 'right', + min: 0, + ticks: { + callback: function (value: number) { + if (value >= 1000000000) return (value / 1000000000).toFixed(1).replace(/\.0$/, '') + 'G'; + else if (value >= 1000000) return (value / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + else if (value >= 1000) return (value / 1000).toFixed(1).replace(/\.0$/, '') + 'k'; + return value.toLocaleString(); + }, + }, + title: { + display: true, + text: 'Requests', + }, + grid: { + drawOnChartArea: false, + }, + }, x: { type: 'time', + stacked, time: { unit: timeUnit, unitStepSize: stepSize, @@ -303,7 +371,7 @@ export const createHistoricalChartOptions = (timeUnit: string = 'day', stepSize: }, title: { display: true, - text: 'Time', + text: axisTitle, }, }, }, @@ -322,9 +390,12 @@ const commonDatasetOptions = { /** * Creates chart datasets configuration for statistics visualization * @param {number} sampleCount - Number of data points to initialize with NaN values + * @param {object} datasetOverrides - Extra per-dataset options merged into every dataset + * (e.g. { pointRadius: 0 } to hide always-on markers). Defaults to none. * @returns {Array} Array of dataset configurations for the chart */ -export const createChartDatasets = (sampleCount: number) => [ +export const createChartDatasets = (sampleCount: number, datasetOverrides: Record = {}) => { + const datasets = [ { label: 'Request', borderColor: '#2196F3', @@ -392,14 +463,113 @@ export const createChartDatasets = (sampleCount: number) => [ data: Array.from({ length: sampleCount }).fill(NaN), ...commonDatasetOptions, }, -]; + ]; + + // Merge any caller-supplied overrides into every dataset. + return datasets.map((dataset) => ({ ...dataset, ...datasetOverrides })); +}; + +/** + * Creates datasets for the HTTP status-code view (1xx..5xx), styled for a + * stacked bar chart with Fastly-like colours. + * @param {number} sampleCount - Number of data points to initialize with NaN values + * @param {object} datasetOverrides - Extra per-dataset options merged into every dataset + * @returns {Array} Array of status-code dataset configurations + */ +export const createStatusDatasets = (sampleCount: number, datasetOverrides: Record = {}) => { + const series = [ + { label: 'Info (1xx)', color: '#3B82F6' }, + { label: 'Success (2xx)', color: '#22C55E' }, + { label: 'Redirect (3xx)', color: '#EC4899' }, + { label: 'Client Error (4xx)', color: '#F59E0B' }, + { label: 'Server Error (5xx)', color: '#06B6D4' }, + ]; + + return series.map((s) => ({ + label: s.label, + borderColor: s.color, + backgroundColor: s.color, + yAxisID: 'y_cnt', + data: Array.from({ length: sampleCount }).fill(NaN), + borderWidth: 0, + // Make adjacent bars touch, like the Fastly service-overview chart. + barPercentage: 1.0, + categoryPercentage: 1.0, + ...datasetOverrides, + })); +}; + +/** + * Creates stacked-bar datasets from an explicit list of {label, color} series + * (used by the per-class status drill-down views: 3xx / 4xx / 5xx details). + * @param {Array} series - Series descriptors ({ label, color }) + * @param {number} sampleCount - Number of data points to initialize with NaN values + * @param {object} datasetOverrides - Extra per-dataset options merged into every dataset + * @returns {Array} Array of dataset configurations + */ +export const createStatusDetailDatasets = ( + series: { label: string; color: string }[], + sampleCount: number, + datasetOverrides: Record = {}, +) => { + return series.map((s) => ({ + label: s.label, + borderColor: s.color, + backgroundColor: s.color, + yAxisID: 'y_cnt', + data: Array.from({ length: sampleCount }).fill(NaN), + borderWidth: 0, + barPercentage: 1.0, + categoryPercentage: 1.0, + ...datasetOverrides, + })); +}; + +/** + * Creates datasets for the bandwidth view: edge (bytes delivered to end users) + * and origin (bytes received from origin), rendered as line/area series. + * @param {number} sampleCount - Number of data points to initialize with NaN values + * @param {object} datasetOverrides - Extra per-dataset options merged into every dataset + * @returns {Array} Array of bandwidth dataset configurations + */ +export const createBandwidthDatasets = (sampleCount: number, datasetOverrides: Record = {}) => { + const byteSeries = [ + { label: 'Edge', color: '#2196F3' }, + { label: 'Origin', color: '#FF7043' }, + ].map((s) => ({ + label: s.label, + borderColor: s.color, + backgroundColor: s.color + '20', + yAxisID: 'y_cnt', + data: Array.from({ length: sampleCount }).fill(NaN), + ...commonDatasetOptions, + ...datasetOverrides, + })); + + // Origin request count on the secondary (right) count axis, drawn as a dashed line. + const originRequests = { + label: 'Origin requests', + borderColor: '#9C27B0', + backgroundColor: '#9C27B020', + yAxisID: 'y_cnt2', + data: Array.from({ length: sampleCount }).fill(NaN), + fill: false, + tension: 0.3, + borderWidth: 1, + borderDash: [5, 4], + ...datasetOverrides, + }; + + return [...byteSeries, originRequests]; +}; /** * Creates complete chart data configuration with labels and datasets * @param {number} sampleCount - Number of data points to initialize + * @param {object} datasetOverrides - Extra per-dataset options (see createChartDatasets) * @returns {object} Complete chart data object with timestamps and datasets */ -export const createChartData = (sampleCount: number) => { +export const createChartData = (sampleCount: number, datasetOverrides: Record = {}) => { const now = Date.now(); // en millisecondes const timestamps: number[] = Array.from({ length: sampleCount }, (_, i) => { return now - (sampleCount - 1 - i) * 1000; @@ -407,7 +577,7 @@ export const createChartData = (sampleCount: number) => { return { labels: timestamps, - datasets: createChartDatasets(sampleCount), + datasets: createChartDatasets(sampleCount, datasetOverrides), }; }; From 124d46b44873ed241de6c15290ce621f9043d74c Mon Sep 17 00:00:00 2001 From: Vincent ROBERT Date: Tue, 21 Jul 2026 22:33:18 +0200 Subject: [PATCH 3/4] perf(tabs): lazy-mount tab panels to stop background polling Enable PrimeVue Tabs `lazy` so the inactive panel unmounts. The Real-time StatCard no longer keeps its 1s rt.fastly.com polling (and hidden-chart re-render) running while the History tab is open. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/views/HomeView.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index fec9bf4..146bac6 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -238,7 +238,7 @@ const vclVersionIsDefined = computed(() => credentialsStore.vclVersionIsDefined.
- + Real-time History From 9b01e962a655d9ac982d7f4d84f972f932185b6d Mon Sep 17 00:00:00 2001 From: Vincent ROBERT Date: Tue, 21 Jul 2026 22:33:18 +0200 Subject: [PATCH 4/4] feat(history): Fastly-style per-minute History analytics tab Replace the daily-only History view with Fastly-style observability: - Data Resolution (minute/hour/day) + Time Range (5m..1mo + Custom) + UTC/Local timezone controls; defaults to per-minute over the last 24h. Resolution options respect Fastly's query-size and 35-day minute retention limits; custom ranges are validated (<=2y, not in the future). - Metric views: Cache, HTTP Status (stacked), Status 3xx/4xx/5xx drill-downs (with an "Other Nxx" remainder), and Bandwidth (edge/origin bytes + origin request count on a secondary axis). - Drag across the chart to zoom into a window (reloads at the finest valid resolution) with Reset; click a legend entry to solo a series. - Hover tooltips preserved with markers hidden; selection persists in the URL (res/range/tz/view/from/to) and syncs with the Upsun console. - Add timezone/display helpers to dateTools; remove the now-unused weekly/monthly/yearly period helpers orphaned by the controls rewrite. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/project/QueryCard.vue | 1284 ++++++++++++++++---------- src/utils/dateTools.ts | 96 +- 2 files changed, 860 insertions(+), 520 deletions(-) diff --git a/src/components/project/QueryCard.vue b/src/components/project/QueryCard.vue index 13f70b6..7e65434 100644 --- a/src/components/project/QueryCard.vue +++ b/src/components/project/QueryCard.vue @@ -1,6 +1,7 @@ + + diff --git a/src/utils/dateTools.ts b/src/utils/dateTools.ts index cc53856..a52c72d 100644 --- a/src/utils/dateTools.ts +++ b/src/utils/dateTools.ts @@ -1,12 +1,3 @@ -// Date period constants -export const DATE_PERIODS = { - WEEK: 'week', - MONTH: 'month', - YEAR: 'year', -} as const; - -export type DatePeriod = (typeof DATE_PERIODS)[keyof typeof DATE_PERIODS]; - // Utility functions for timestamp formatting export const formatDateForUrl = (date: Date): string => { return Math.floor(date.getTime() / 1000).toString(); // Convert to timestamp in seconds @@ -35,47 +26,62 @@ export const parseDateFromUrl = (timestampStr: string): Date | null => { return isNaN(date.getTime()) ? null : date; }; -// Get current week's start (Monday) and end (Sunday) -export const getCurrentWeek = (): [Date, Date] => { - const now = new Date(); - const dayOfWeek = now.getDay(); - const diff = now.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday - - const startOfWeek = new Date(now.setDate(diff)); - const endOfWeek = new Date(startOfWeek); - endOfWeek.setDate(startOfWeek.getDate() + 6); +// Timezone modes for the historical view +export const TIME_ZONES = { + UTC: 'utc', + LOCAL: 'local', +} as const; - return [startOfWeek, endOfWeek]; +export type TimeZoneMode = (typeof TIME_ZONES)[keyof typeof TIME_ZONES]; + +/** + * Transforms a real epoch (ms) into a value that, when rendered by a + * local-timezone chart formatter, displays the desired wall-clock time. + * In 'local' mode the epoch is returned unchanged; in 'utc' mode it is + * shifted so the local formatter prints the UTC wall-clock instead. + * The offset is computed per-timestamp so DST transitions are handled. + * + * Caveat: toWallClockDate()/fromWallClock() derive the offset from slightly + * different instants, so the round-trip can drift by up to 1h for wall-clock + * times that land within a DST transition. This only affects the custom-range + * picker seed in UTC mode near a transition and is considered acceptable. + */ +export const toDisplayTimestamp = (epochMs: number, tz: TimeZoneMode): number => { + if (tz === TIME_ZONES.UTC) { + return epochMs + new Date(epochMs).getTimezoneOffset() * 60000; + } + return epochMs; }; -// Get current month's start and end -export const getCurrentMonth = (): [Date, Date] => { - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - return [startOfMonth, endOfMonth]; +/** + * Interprets the wall-clock fields of a Date (as produced by a date/time + * picker) in the selected timezone and returns the real epoch (ms). + * Inverse of toDisplayTimestamp. + */ +export const fromWallClock = (date: Date, tz: TimeZoneMode): number => { + if (tz === TIME_ZONES.UTC) { + return date.getTime() - date.getTimezoneOffset() * 60000; + } + return date.getTime(); }; -// Get current year's start and end -export const getCurrentYear = (): [Date, Date] => { - const now = new Date(); - const startOfYear = new Date(now.getFullYear(), 0, 1); - const endOfYear = new Date(now.getFullYear(), 11, 31); - - return [startOfYear, endOfYear]; +/** + * Builds a Date whose local wall-clock fields equal the given epoch's + * wall-clock in the selected timezone (used to seed the custom pickers). + */ +export const toWallClockDate = (epochMs: number, tz: TimeZoneMode): Date => { + return new Date(toDisplayTimestamp(epochMs, tz)); }; -// Get current period based on selected option -export const getCurrentPeriod = (period: string): [Date, Date] => { - switch (period) { - case DATE_PERIODS.WEEK: - return getCurrentWeek(); - case DATE_PERIODS.MONTH: - return getCurrentMonth(); - case DATE_PERIODS.YEAR: - return getCurrentYear(); - default: - return getCurrentMonth(); - } +/** + * Chooses a sensible x-axis tick unit from the total span of the range, + * independent of the data resolution (mirrors the Fastly chart behaviour). + */ +export const displayUnitForSpan = (spanMs: number): string => { + const HOUR = 3600000; + const DAY = 86400000; + if (spanMs <= 2 * HOUR) return 'minute'; + if (spanMs <= 3 * DAY) return 'hour'; + if (spanMs <= 60 * DAY) return 'day'; + return 'month'; };