Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
db1bab9
feat(devices): add platform and bundle filters to device table
cursoragent Jul 30, 2026
a237d60
style(tests): add blank line between device pagination describes
cursoragent Jul 30, 2026
038bc5d
ci: retrigger cloudflare shards after infra flake
cursoragent Jul 30, 2026
4d06f7e
fix(devices): ignore stale bundle list responses after app switch
cursoragent Jul 30, 2026
25711f5
fix(devices): clear bundle filter and align CF current-state filters
cursoragent Jul 30, 2026
ebfd886
fix(devices): polish device filter UX from review feedback
cursoragent Jul 30, 2026
ed12fde
fix(devices): align CF search with current-state filters
cursoragent Jul 30, 2026
0271d6a
test(devices): cover CF aggregated search + platform filter
cursoragent Jul 30, 2026
46d5c26
fix(devices): move platform/bundle filters into Filters dropdown
cursoragent Jul 30, 2026
db14285
fix(devices): style Filters dropdown extras without menu chrome
cursoragent Jul 30, 2026
56d758b
fix(visual-diff): tolerate missing platform filter on base capture
cursoragent Jul 30, 2026
b035174
fix(devices): align Filters dropdown fields and checkboxes
cursoragent Jul 30, 2026
fcef040
fix(devices): open platform/bundle filters in a modal
riderx Jul 30, 2026
92a86b3
fix(devices): invalidate in-flight loads on app switch
riderx Jul 30, 2026
4957c59
fix(devices): harden Filters modal a11y and clear path
riderx Jul 30, 2026
3b4dac6
fix(devices): filter CF custom-id mode on aggregated state
riderx Jul 30, 2026
efae04c
fix(devices): finish clear reload race and scoped search
riderx Jul 30, 2026
a9d2494
fix(visual-diff): tolerate Filters modal missing on base
riderx Jul 30, 2026
74e5b12
fix(devices): restore focus to Filters button on modal close
riderx Jul 30, 2026
3272f3c
fix(devices): unique Filters modal label id and stricter visual-diff …
riderx Jul 30, 2026
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
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,9 @@
"production-plugin-device": "Production plugin device",
"production-plugin-device-within-7-days": "Production Plugin Device (within 7 days)",
"all-billing-cycles": "All billing cycles",
"all-bundles": "All bundles",
"all-plans": "All plans",
"all-platforms": "All platforms",
"access-required-description": "You don't have access to this section. Ask one of the people below, who can manage it for your organization.",
"access-contact-people": "People who can help",
"audit-access-required": "Audit log access required",
Expand Down
126 changes: 122 additions & 4 deletions src/components/tables/DeviceTable.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import type { TableColumn } from '../comp_def'
import type { Database } from '~/types/supabase.types'
import { h, ref } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
Expand All @@ -20,6 +21,7 @@ const emit = defineEmits(['addDevice'])

// TODO: delete the old version check when all devices uses the new version system
type Device = Database['public']['Tables']['devices']['Row']
type PlatformOs = Database['public']['Enums']['platform_os']

const { t } = useI18n()
const supabase = useSupabase()
Expand All @@ -38,7 +40,11 @@ const filters = ref({
Override: false,
CustomId: false,
})
const selectedPlatform = ref<'' | PlatformOs>('')
const selectedVersionName = ref(props.versionName ?? '')
const bundleNames = ref<string[]>([])
const offset = 10
const selectControlClass = 'd-select d-select-bordered h-10 min-h-10 w-full max-w-56 rounded-md border-gray-300 bg-white text-sm text-slate-900 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-600 dark:bg-gray-800 dark:text-white'
const columns = ref<TableColumn[]>([
{
label: t('device-id'),
Expand Down Expand Up @@ -95,10 +101,22 @@ function getSearchTerm() {
return trimmed.length ? trimmed : undefined
}

function getVersionNameFilter() {
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
const selected = selectedVersionName.value.trim()
if (selected)
return selected
return props.versionName || undefined
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

function getPlatformFilter(): PlatformOs | undefined {
return selectedPlatform.value || undefined
}

function getQuerySignature() {
return JSON.stringify({
appId: props.appId,
versionName: props.versionName,
versionName: getVersionNameFilter(),
platform: getPlatformFilter() ?? '',
search: getSearchTerm(),
order: getActiveOrder(columns.value),
override: filters.value.Override,
Expand All @@ -107,6 +125,30 @@ function getQuerySignature() {
})
}

async function loadBundleNames() {
if (!props.appId)
return

const { data, error } = await supabase
.from('app_versions')
.select('name')
.eq('app_id', props.appId)
.eq('deleted', false)
.order('created_at', { ascending: false })
.limit(200)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

if (error || !data) {
bundleNames.value = []
return
}

const names = [...new Set(data.map(row => row.name).filter(Boolean))]
const selected = getVersionNameFilter()
if (selected && !names.includes(selected))
names.unshift(selected)
bundleNames.value = names
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

async function getDevicesID() {
let req = supabase
.from('channel_devices')
Expand Down Expand Up @@ -149,7 +191,8 @@ async function countDevices() {
body: JSON.stringify({
count: true,
appId: props.appId,
versionName: props.versionName,
versionName: getVersionNameFilter(),
platform: getPlatformFilter(),
devicesId: deviceIds.length > 0 ? deviceIds : undefined,
search: searchTerm,
order: getActiveOrder(columns.value),
Expand Down Expand Up @@ -235,6 +278,10 @@ async function refreshData() {
}
}

const debouncedReload = useDebounceFn(() => {
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
reload()
}, 300)

async function fetchDevicesPage(cursor: string | undefined | null) {
const ids = await resolveDeviceIds()
const searchTerm = getSearchTerm()
Expand All @@ -252,7 +299,8 @@ async function fetchDevicesPage(cursor: string | undefined | null) {
},
body: JSON.stringify({
appId: props.appId,
versionName: props.versionName,
versionName: getVersionNameFilter(),
platform: getPlatformFilter(),
devicesId: ids.length ? ids : undefined,
search: searchTerm,
order: getActiveOrder(columns.value),
Expand Down Expand Up @@ -403,10 +451,80 @@ async function ensureVersionNames(devices: Device[]) {
device.version_name = versionMap[id]
})
}

onMounted(async () => {
await loadBundleNames()
Comment thread
riderx marked this conversation as resolved.
Resolved
})

watch(() => props.appId, async () => {
selectedPlatform.value = ''
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
selectedVersionName.value = props.versionName ?? ''
await loadBundleNames()
await refreshData()
})
Comment thread
riderx marked this conversation as resolved.
Resolved

watch(() => props.versionName, (value) => {
if (value && !selectedVersionName.value)
selectedVersionName.value = value
})

watch([selectedPlatform, selectedVersionName], () => {
debouncedReload()
})
</script>

<template>
<div>
<div class="flex flex-wrap items-end gap-3 px-4 pt-4">
<div class="flex min-w-40 flex-col gap-1">
<label for="device-table-platform-filter" class="text-xs font-medium text-slate-600 dark:text-gray-300">
{{ t('platform') }}
</label>
<select
id="device-table-platform-filter"
v-model="selectedPlatform"
:class="selectControlClass"
:aria-label="t('platform')"
data-test="device-platform-filter"
>
<option value="">
{{ t('all-platforms') }}
</option>
<option value="ios">
{{ t('platform-ios') }}
</option>
<option value="android">
{{ t('platform-android') }}
</option>
<option value="electron">
{{ t('platform-electron') }}
</option>
</select>
</div>
<div class="flex min-w-48 flex-col gap-1">
<label for="device-table-bundle-filter" class="text-xs font-medium text-slate-600 dark:text-gray-300">
{{ t('bundle') }}
</label>
<select
id="device-table-bundle-filter"
v-model="selectedVersionName"
:class="selectControlClass"
:aria-label="t('bundle')"
data-test="device-bundle-filter"
>
<option value="">
{{ t('all-bundles') }}
</option>
<option
v-for="name in bundleNames"
:key="name"
:value="name"
>
{{ name }}
</option>
</select>
</div>
</div>
<DataTable
v-model:filters="filters" v-model:columns="columns" v-model:current-page="currentPage" v-model:search="search"
:total="total" :offset="offset" :element-list="elements"
Expand Down
24 changes: 20 additions & 4 deletions supabase/functions/_backend/private/devices.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import type { Order } from '../utils/types.ts'
import { z } from 'zod'
import { Hono } from 'hono/tiny'
import { safeParseSchema } from '../utils/schema_validation.ts'
import { z } from 'zod'
import { parseBody, simpleError, useCors } from '../utils/hono.ts'
import { middlewareAuth } from '../utils/hono_middleware.ts'
import { cloudlog } from '../utils/logging.ts'
import { appIdSchema, cursorSchema, deviceIdSchema, hasInvalidQueryLimitInput, hasUnsafeDevicesQueryText, queryLimitSchema, safeQueryTextSchema } from '../utils/privateAnalyticsValidation.ts'
import { checkPermission } from '../utils/rbac.ts'
import { safeParseSchema } from '../utils/schema_validation.ts'
import { countDevices, countInstallSources, readDevices } from '../utils/stats.ts'
import { Constants } from '../utils/supabase.types.ts'

interface DataDevice {
appId: string
count?: boolean
installSourceCounts?: boolean
versionName?: string
platform?: typeof Constants.public.Enums.platform_os[number]
devicesId?: string[]
deviceIds?: string[] // TODO: remove when migration is done
installSources?: string[]
Expand All @@ -31,11 +33,13 @@ const orderItemSchema = z.object({
key: z.string().max(64),
sortable: z.enum(['asc', 'desc']).optional(),
})
const platformSchema = z.enum(Constants.public.Enums.platform_os)
const devicesBodySchema = z.object({
appId: appIdSchema,
count: z.boolean().optional(),
installSourceCounts: z.boolean().optional(),
versionName: safeQueryTextSchema.optional(),
platform: platformSchema.optional(),
devicesId: z.array(deviceIdSchema).optional(),
deviceIds: z.array(deviceIdSchema).optional(),
installSources: z.array(safeQueryTextSchema).optional(),
Expand Down Expand Up @@ -70,11 +74,23 @@ app.post('/', middlewareAuth(), async (c) => {
const devicesIds = body.devicesId ?? body.deviceIds ?? []
if (body.installSourceCounts)
return c.json({ installSources: await countInstallSources(c, body.appId) })
if (body.count)
return c.json({ count: await countDevices(c, body.appId, body.customIdMode ?? false, devicesIds, body.versionName, body.search?.trim()) })
if (body.count) {
return c.json({
count: await countDevices(
c,
body.appId,
body.customIdMode ?? false,
devicesIds,
body.versionName,
body.search?.trim(),
body.platform,
),
})
}
return c.json(await readDevices(c, {
app_id: body.appId,
version_name: body.versionName,
platform: body.platform,
deviceIds: devicesIds,
installSources: body.installSources,
search: body.search,
Expand Down
40 changes: 40 additions & 0 deletions supabase/functions/_backend/utils/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,13 +937,22 @@ GROUP BY install_source`
return counts
}

function platformOsToCFDouble(platform: Database['public']['Enums']['platform_os']): number {
if (platform === 'ios')
return 1
if (platform === 'electron')
return 2
return 0
}

export async function countDevicesCF(
c: Context,
app_id: string,
customIdMode: boolean,
deviceIds: string[] = [],
versionName?: string,
search?: string,
platform?: Database['public']['Enums']['platform_os'],
) {
// Use Analytics Engine DEVICE_INFO for counting devices
const conditions = [`index1 = '${escapeSqlString(app_id)}'`]
Expand Down Expand Up @@ -972,6 +981,29 @@ export async function countDevicesCF(
if (versionName)
conditions.push(`blob2 = '${escapeSqlString(versionName)}'`)

// Match latest aggregated platform for current-state filtering.
if (platform) {
const platformValue = platformOsToCFDouble(platform)
const query = `SELECT COUNT() AS total
FROM (
SELECT argMax(double1, timestamp) AS platform
FROM device_info
WHERE ${conditions.join(' AND ')}
GROUP BY blob1
)
WHERE platform = ${platformValue}`

cloudlog({ requestId: c.get('requestId'), message: 'countDevicesCF query', query })
try {
const res = await runQueryToCFA<{ total: number }>(c, query)
return res[0]?.total ?? 0
}
catch (e) {
cloudlogErr({ requestId: c.get('requestId'), message: 'Error reading device count from Analytics Engine', error: serializeError(e), query })
}
return 0
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

const query = `SELECT COUNT(DISTINCT blob1) AS total
FROM device_info
WHERE ${conditions.join(' AND ')}`
Expand Down Expand Up @@ -1053,12 +1085,20 @@ function buildReadDevicesCFCustomIdsCondition(customIds: string[] | undefined) {
return `custom_id IN (${customIdsList})`
}

function buildReadDevicesCFPlatformCondition(platform: ReadDevicesParams['platform']) {
if (!platform)
return ''
return `platform = ${platformOsToCFDouble(platform)}`
}

function buildReadDevicesCFOuterConditions(params: ReadDevicesParams, devicesOrder: DevicesOrderCF | null) {
const conditions = [
buildReadDevicesCFCursorCondition(params.cursor, devicesOrder),
buildReadDevicesCFUpdatedAtGtCondition(params.updated_at_gt),
// Match the latest aggregated custom_id, not historical event rows.
buildReadDevicesCFCustomIdsCondition(params.customIds),
// Match the latest aggregated platform, not historical event rows.
buildReadDevicesCFPlatformCondition(params.platform),
Comment thread
riderx marked this conversation as resolved.
Resolved
]
return conditions.filter(Boolean)
}
Expand Down
5 changes: 3 additions & 2 deletions supabase/functions/_backend/utils/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,11 +476,12 @@ export function countDevices(
deviceIds: string[] = [],
versionName?: string,
search?: string,
platform?: Database['public']['Enums']['platform_os'],
) {
const trimmedSearch = search?.trim()
if (shouldUseAnalyticsEngine(c))
return countDevicesCF(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch)
return countDevicesSB(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch)
return countDevicesCF(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch, platform)
return countDevicesSB(c, app_id, customIdMode, deviceIds, versionName, trimmedSearch, platform)
}

export function countInstallSources(c: Context, app_id: string) {
Expand Down
7 changes: 7 additions & 0 deletions supabase/functions/_backend/utils/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1532,6 +1532,9 @@ export async function readDevicesSB(c: Context, params: ReadDevicesParams, custo
if (params.version_name)
query = query.eq('version_name', params.version_name)

if (params.platform)
query = query.eq('platform', params.platform)

if (params.installSources?.length)
query = query.in('install_source', params.installSources)

Expand Down Expand Up @@ -1606,6 +1609,7 @@ export async function countDevicesSB(
deviceIds: string[] = [],
versionName?: string,
search?: string,
platform?: Database['public']['Enums']['platform_os'],
) {
let req = supabaseAdmin(c)
.from('devices')
Expand Down Expand Up @@ -1636,6 +1640,9 @@ export async function countDevicesSB(
if (versionName)
req = req.eq('version_name', versionName)

if (platform)
req = req.eq('platform', platform)

const { count, error } = await req

if (error) {
Expand Down
2 changes: 2 additions & 0 deletions supabase/functions/_backend/utils/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export interface NativeVersionUsage {
export interface ReadDevicesParams {
app_id: string
version_name?: string | undefined
/** Exact platform filter (`ios` | `android` | `electron`) */
platform?: Database['public']['Enums']['platform_os']
deviceIds?: string[]
/** Exact custom_id match filter (case-sensitive, already trimmed by callers) */
customIds?: string[]
Expand Down
Loading
Loading