Panels: per-device dashboard sets, paired by code
A panel is one screen and the ordered set of whole dashboards it shows, so a hallway tablet and a workshop tablet carry different sets without either dashboard knowing about the other. More than one and the device draws a rail to switch between them — the same rail the editor puts on screen, because the wall has it and it takes room off the canvas. A screen has no keyboard, so it pairs rather than logs in: it shows a six-character code, somebody approves it against a panel from the dashboards overview, and the credential that mints reaches that panel's published dashboards and the message endpoints its widgets speak, and nothing else. Deleting the panel revokes it. Closes the per-device view and the kiosk credential; supersedes the multi-page/multi-section UI, since a page is now a dashboard of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
This commit is contained in:
@@ -1950,6 +1950,110 @@ export const PageDef_OutputSchema = {
|
||||
description: 'One tab of a dashboard.'
|
||||
} as const;
|
||||
|
||||
export const PairRequestSchema = {
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
title: 'Code'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
title: 'PairRequest'
|
||||
} as const;
|
||||
|
||||
export const PairStartedSchema = {
|
||||
properties: {
|
||||
code: {
|
||||
type: 'string',
|
||||
title: 'Code'
|
||||
},
|
||||
secret: {
|
||||
type: 'string',
|
||||
title: 'Secret'
|
||||
},
|
||||
expires_in: {
|
||||
type: 'integer',
|
||||
title: 'Expires In',
|
||||
default: 600
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['code', 'secret'],
|
||||
title: 'PairStarted',
|
||||
description: 'What the device puts on the wall, and what it polls with.'
|
||||
} as const;
|
||||
|
||||
export const PairStatusSchema = {
|
||||
properties: {
|
||||
access_token: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Access Token'
|
||||
},
|
||||
panel: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Panel'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'PairStatus',
|
||||
description: 'Nothing yet, or the credential and the panel it is for.'
|
||||
} as const;
|
||||
|
||||
export const PanelDefSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
title: 'Id'
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
title: 'Title',
|
||||
default: ''
|
||||
},
|
||||
dashboards: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Dashboards'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
title: 'PanelDef',
|
||||
description: 'One device, and what it shows.'
|
||||
} as const;
|
||||
|
||||
export const PanelsConfigSchema = {
|
||||
properties: {
|
||||
panels: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/PanelDef'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Panels'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
title: 'PanelsConfig',
|
||||
description: 'Every panel this installation knows about.'
|
||||
} as const;
|
||||
|
||||
export const PlacementSchema = {
|
||||
properties: {
|
||||
x: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
|
||||
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, ArtifactsPutArtifactData, ArtifactsPutArtifactResponse, ArtifactsGetArtifactData, ArtifactsGetArtifactResponse, CloudReadStatusResponse, CloudEnrollData, CloudEnrollResponse, CloudDisconnectResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PanelsReadPanelsResponse, PanelsSavePanelsData, PanelsSavePanelsResponse, PanelsStartPairingResponse, PanelsPollPairingData, PanelsPollPairingResponse, PanelsApprovePairingData, PanelsApprovePairingResponse, PanelsReadPanelData, PanelsReadPanelResponse, PrivateCreateUserData, PrivateCreateUserResponse, RunsCreateRunData, RunsCreateRunResponse, RunsCreateSweepData, RunsCreateSweepResponse, RunsReadRunsData, RunsReadRunsResponse, RunsReadRunData, RunsReadRunResponse, RunsCancelRunData, RunsCancelRunResponse, RunsReadMetricsData, RunsReadMetricsResponse, RunsCompareMetricData, RunsCompareMetricResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse, WorkersReadWorkersResponse, WorkersIssueTokenData, WorkersIssueTokenResponse, WorkersReadRuntimeResponse } from './types.gen';
|
||||
|
||||
export class AlertsService {
|
||||
/**
|
||||
@@ -1392,6 +1392,141 @@ export class ObservabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
export class PanelsService {
|
||||
/**
|
||||
* Read Panels
|
||||
* Every panel, and what each one shows.
|
||||
* @returns PanelsConfig Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readPanels(): CancelablePromise<PanelsReadPanelsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/panels/'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Panels
|
||||
* Replace the panels. Takes effect on the devices' next read.
|
||||
*
|
||||
* A panel that disappears here takes its credential with it, so this is also
|
||||
* how a device is unpaired.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns PanelsConfig Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static savePanels(data: PanelsSavePanelsData): CancelablePromise<PanelsSavePanelsResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'PUT',
|
||||
url: '/api/v1/panels/',
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Pairing
|
||||
* A device asks to be adopted. Unauthenticated, by necessity.
|
||||
*
|
||||
* All this hands out is a code that means nothing until somebody with an
|
||||
* account approves it, so the worst an unwelcome caller achieves is a line in
|
||||
* a dictionary that expires ten minutes later.
|
||||
* @returns PairStarted Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static startPairing(): CancelablePromise<PanelsStartPairingResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/panels/pair'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll Pairing
|
||||
* Has anyone claimed this device yet?
|
||||
*
|
||||
* Answers the same 404 for a code that never existed, one that expired and
|
||||
* one polled with the wrong secret: a caller reading a code off a wall learns
|
||||
* nothing by guessing.
|
||||
* @param data The data for the request.
|
||||
* @param data.code
|
||||
* @param data.secret
|
||||
* @returns PairStatus Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static pollPairing(data: PanelsPollPairingData): CancelablePromise<PanelsPollPairingResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/panels/pair/{code}',
|
||||
path: {
|
||||
code: data.code
|
||||
},
|
||||
query: {
|
||||
secret: data.secret
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve Pairing
|
||||
* Say which panel the device showing this code is.
|
||||
*
|
||||
* The credential names the approver, so what the panel does stays
|
||||
* attributable to a person rather than to nobody.
|
||||
* @param data The data for the request.
|
||||
* @param data.panelId
|
||||
* @param data.requestBody
|
||||
* @returns Message Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static approvePairing(data: PanelsApprovePairingData): CancelablePromise<PanelsApprovePairingResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/panels/{panel_id}/pair',
|
||||
path: {
|
||||
panel_id: data.panelId
|
||||
},
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read Panel
|
||||
* One panel — read by a person, or by the device holding its credential.
|
||||
*
|
||||
* Declared after the pairing routes so a device polling ``/panels/pair/...``
|
||||
* is never mistaken for someone reading a panel named ``pair``.
|
||||
* @param data The data for the request.
|
||||
* @param data.panelId
|
||||
* @returns PanelDef Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static readPanel(data: PanelsReadPanelData): CancelablePromise<PanelsReadPanelResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/panels/{panel_id}',
|
||||
path: {
|
||||
panel_id: data.panelId
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateService {
|
||||
/**
|
||||
* Create User
|
||||
|
||||
@@ -707,6 +707,43 @@ export type PageDef_Output = {
|
||||
sections?: Array<SectionDef_Output>;
|
||||
};
|
||||
|
||||
export type PairRequest = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the device puts on the wall, and what it polls with.
|
||||
*/
|
||||
export type PairStarted = {
|
||||
code: string;
|
||||
secret: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Nothing yet, or the credential and the panel it is for.
|
||||
*/
|
||||
export type PairStatus = {
|
||||
access_token?: (string | null);
|
||||
panel?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* One device, and what it shows.
|
||||
*/
|
||||
export type PanelDef = {
|
||||
id: string;
|
||||
title?: string;
|
||||
dashboards?: Array<(string)>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every panel this installation knows about.
|
||||
*/
|
||||
export type PanelsConfig = {
|
||||
panels?: Array<PanelDef>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a widget sits in its section's grid, in grid units.
|
||||
*/
|
||||
@@ -1356,6 +1393,36 @@ export type ObservabilityReadDeadLettersData = {
|
||||
|
||||
export type ObservabilityReadDeadLettersResponse = (Array<DeadLetter>);
|
||||
|
||||
export type PanelsReadPanelsResponse = (PanelsConfig);
|
||||
|
||||
export type PanelsSavePanelsData = {
|
||||
requestBody: PanelsConfig;
|
||||
};
|
||||
|
||||
export type PanelsSavePanelsResponse = (PanelsConfig);
|
||||
|
||||
export type PanelsStartPairingResponse = (PairStarted);
|
||||
|
||||
export type PanelsPollPairingData = {
|
||||
code: string;
|
||||
secret?: string;
|
||||
};
|
||||
|
||||
export type PanelsPollPairingResponse = (PairStatus);
|
||||
|
||||
export type PanelsApprovePairingData = {
|
||||
panelId: string;
|
||||
requestBody: PairRequest;
|
||||
};
|
||||
|
||||
export type PanelsApprovePairingResponse = (Message);
|
||||
|
||||
export type PanelsReadPanelData = {
|
||||
panelId: string;
|
||||
};
|
||||
|
||||
export type PanelsReadPanelResponse = (PanelDef);
|
||||
|
||||
export type PrivateCreateUserData = {
|
||||
requestBody: PrivateUserCreate;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Check, Loader2, Plus, Search } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useRef, useState } from "react"
|
||||
import { type ReactNode, useRef, useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DialogTrigger } from "@/components/ui/dialog"
|
||||
@@ -38,6 +38,7 @@ export function OverviewToolbar({
|
||||
draftCount,
|
||||
publishing,
|
||||
onPublishAll,
|
||||
children,
|
||||
}: {
|
||||
search: string
|
||||
onSearch: (value: string) => void
|
||||
@@ -49,6 +50,8 @@ export function OverviewToolbar({
|
||||
draftCount: number
|
||||
publishing: boolean
|
||||
onPublishAll: () => void
|
||||
/** Anything this particular overview adds, drawn ahead of the shared icons. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
@@ -62,6 +65,7 @@ export function OverviewToolbar({
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{children}
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import {
|
||||
Check,
|
||||
@@ -68,9 +68,11 @@ import {
|
||||
sectionsOf,
|
||||
widgetsOf,
|
||||
} from "./DashboardView"
|
||||
import { PanelRail, RAIL_INSET } from "./PanelRail"
|
||||
import { DashboardPanel, WidgetPanel } from "./panels"
|
||||
import {
|
||||
dashboardKeys,
|
||||
panelsQueryOptions,
|
||||
useDiscardDashboardDraft,
|
||||
usePublishDashboard,
|
||||
useSaveDashboard,
|
||||
@@ -351,6 +353,18 @@ export function DashboardEditor({
|
||||
const active = widgets.find((widget) => widget.id === selected) ?? null
|
||||
const panelOpen = Boolean(active) || settingsOpen
|
||||
|
||||
// A dashboard hanging on a panel beside others is drawn with a rail over it,
|
||||
// which takes room off the canvas. Show that here rather than letting someone
|
||||
// arrange against a width the wall does not have.
|
||||
// ponytail: the first such panel wins — the others differ only in scale.
|
||||
const { data: panels } = useQuery(panelsQueryOptions())
|
||||
const railDashboards =
|
||||
(panels?.panels ?? []).find(
|
||||
(panel) =>
|
||||
(panel.dashboards ?? []).length > 1 &&
|
||||
(panel.dashboards ?? []).includes(draft.name),
|
||||
)?.dashboards ?? null
|
||||
|
||||
const setEdit = (next: boolean) => {
|
||||
setSelected(null)
|
||||
setSettingsOpen(false)
|
||||
@@ -442,11 +456,28 @@ export function DashboardEditor({
|
||||
|
||||
return (
|
||||
<>
|
||||
{railDashboards && !stacked ? (
|
||||
<PanelRail
|
||||
dashboards={railDashboards}
|
||||
current={draft.name}
|
||||
// Editing one dashboard of a panel and editing its neighbour are the
|
||||
// same job, so the rail keeps whichever mode this one is in.
|
||||
linkFor={(name) => ({
|
||||
to: "/dashboards/$name",
|
||||
params: { name },
|
||||
search: edit ? { edit: true } : {},
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
|
||||
panelOpen && "md:pr-[27rem]",
|
||||
)}
|
||||
style={
|
||||
railDashboards && !stacked ? { paddingLeft: RAIL_INSET } : undefined
|
||||
}
|
||||
data-testid="dashboard-canvas"
|
||||
>
|
||||
{body}
|
||||
@@ -457,6 +488,7 @@ export function DashboardEditor({
|
||||
"pointer-events-none absolute inset-0 transition-[right] duration-200",
|
||||
panelOpen && "md:right-[27rem]",
|
||||
)}
|
||||
style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined}
|
||||
>
|
||||
<CanvasTitle>
|
||||
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import { Link, type LinkProps } from "@tanstack/react-router"
|
||||
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** How much room the rail takes, canvas insets included. Mirrors the side
|
||||
* panel's `27rem`: the button plus the gutters either side of it. */
|
||||
export const RAIL_INSET = "4.5rem"
|
||||
|
||||
/** Two letters off the title, so a rail of four reads as four different things. */
|
||||
function initials(label: string): string {
|
||||
const words = label.split(/[\s_-]+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching between the dashboards one panel was assigned.
|
||||
*
|
||||
* Permanent rather than a menu behind a button: a wall panel is glanced at,
|
||||
* and something you have to open first is something nobody opens. It costs
|
||||
* about a fortieth of the canvas, which `CanvasSurface` absorbs by scaling —
|
||||
* the grid itself never changes, so an arrangement made without the rail still
|
||||
* fits with it.
|
||||
*
|
||||
* Reading the dashboards it links to is also what fills the labels, and it
|
||||
* warms the cache for the neighbours so a switch draws immediately.
|
||||
*/
|
||||
export function PanelRail({
|
||||
dashboards,
|
||||
current,
|
||||
linkFor,
|
||||
className,
|
||||
}: {
|
||||
dashboards: string[]
|
||||
current: string
|
||||
linkFor: (name: string) => LinkProps
|
||||
className?: string
|
||||
}) {
|
||||
const titles = useQueries({
|
||||
queries: dashboards.map((name) => dashboardQueryOptions(name)),
|
||||
combine: (results) =>
|
||||
results.map((result, index) => result.data?.title || dashboards[index]),
|
||||
})
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid="panel-rail"
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 overflow-y-auto rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dashboards.map((name, index) => {
|
||||
const label = titles[index]
|
||||
const active = name === current
|
||||
return (
|
||||
<Tooltip key={name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
// `icon-lg` at every width on purpose: a rail is a touch
|
||||
// surface whatever the screen's size, and the panel it hangs
|
||||
// on is 1024 wide as often as it is 400.
|
||||
size="icon-lg"
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium text-muted-foreground",
|
||||
active && "bg-accent text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-testid={`panel-rail-${name}`}
|
||||
>
|
||||
<Link {...linkFor(name)} aria-label={label}>
|
||||
{initials(label)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
|
||||
/**
|
||||
* One dashboard filling whatever screen it landed on.
|
||||
*
|
||||
* The whole of what a wall panel draws, shared by the single-dashboard route
|
||||
* (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows
|
||||
* the same thing either way — the second merely has a rail beside it.
|
||||
*/
|
||||
export function PanelSurface({
|
||||
dashboard,
|
||||
stacked,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
* its size, which reads as nothing at all. Stack it instead. */
|
||||
stacked?: boolean
|
||||
}) {
|
||||
if (stacked) return <DashboardView dashboard={dashboard} stacked />
|
||||
|
||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||
// arranged here.
|
||||
return (
|
||||
<CanvasSurface dashboard={dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard} />}
|
||||
</CanvasSurface>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type PanelDef,
|
||||
type PanelsConfig,
|
||||
PanelsService,
|
||||
} from "@/client"
|
||||
import {
|
||||
dashboardsQueryOptions,
|
||||
panelsQueryOptions,
|
||||
useSavePanels,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
/** The store only accepts this shape, so say so before the request does. */
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
|
||||
/**
|
||||
* Which dashboards hang on which screen, and adopting the screens themselves.
|
||||
*
|
||||
* A panel is a device rather than a document: it has no draft and nothing to
|
||||
* publish, so it lives in a dialog over the dashboards list instead of a page
|
||||
* of its own. Every change saves as it is made — there is no form to submit.
|
||||
*/
|
||||
export function PanelsDialog() {
|
||||
const { data: config } = useQuery(panelsQueryOptions())
|
||||
const { data: dashboards } = useQuery(dashboardsQueryOptions())
|
||||
const save = useSavePanels()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const [name, setName] = useState("")
|
||||
|
||||
const panels = config?.panels ?? []
|
||||
const known = dashboards?.data ?? []
|
||||
|
||||
const write = (next: PanelsConfig) =>
|
||||
save.mutate(next, {
|
||||
onError: (error) => handleError.call(showErrorToast, error as ApiError),
|
||||
})
|
||||
|
||||
const replace = (id: string, panel: PanelDef) =>
|
||||
write({ panels: panels.map((p) => (p.id === id ? panel : p)) })
|
||||
|
||||
const newId = slugify(name)
|
||||
const taken = panels.some((panel) => panel.id === newId)
|
||||
|
||||
return (
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Panels</DialogTitle>
|
||||
<DialogDescription>
|
||||
A panel is one screen and the dashboards it shows. Point the device at
|
||||
the link, and it asks for a code you enter here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{panels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No panels yet. Add one below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{panels.map((panel) => (
|
||||
<PanelRow
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
dashboards={known.map((dashboard) => ({
|
||||
name: dashboard.name,
|
||||
title: dashboard.title || dashboard.name,
|
||||
}))}
|
||||
onChange={(next) => replace(panel.id, next)}
|
||||
onRemove={() =>
|
||||
write({ panels: panels.filter((p) => p.id !== panel.id) })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!newId || taken) return
|
||||
write({ panels: [...panels, { id: newId, title: name.trim() }] })
|
||||
setName("")
|
||||
}}
|
||||
>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<label className="text-sm" htmlFor="new-panel">
|
||||
New panel
|
||||
</label>
|
||||
<Input
|
||||
id="new-panel"
|
||||
value={name}
|
||||
placeholder="hallway"
|
||||
autoComplete="off"
|
||||
data-testid="new-panel-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newId || taken}
|
||||
data-testid="add-panel"
|
||||
>
|
||||
Add panel
|
||||
</Button>
|
||||
</form>
|
||||
{taken ? (
|
||||
<p className="text-sm text-destructive">
|
||||
There is already a panel called {newId}.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelRow({
|
||||
panel,
|
||||
dashboards,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
panel: PanelDef
|
||||
dashboards: { name: string; title: string }[]
|
||||
onChange: (next: PanelDef) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const [code, setCode] = useState("")
|
||||
const assigned = panel.dashboards ?? []
|
||||
|
||||
const pair = useMutation({
|
||||
mutationFn: () =>
|
||||
PanelsService.approvePairing({
|
||||
panelId: panel.id,
|
||||
requestBody: { code: code.trim().toUpperCase() },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCode("")
|
||||
showSuccessToast(
|
||||
"Paired — the screen switches over within a few seconds.",
|
||||
)
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
const toggle = (dashboard: string) =>
|
||||
onChange({
|
||||
...panel,
|
||||
// ponytail: order follows the order they were ticked. Arrows if anyone
|
||||
// asks for them.
|
||||
dashboards: assigned.includes(dashboard)
|
||||
? assigned.filter((name) => name !== dashboard)
|
||||
: [...assigned, dashboard],
|
||||
})
|
||||
|
||||
const link = `${window.location.origin}/panel/${panel.id}`
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={panel.title}
|
||||
placeholder={panel.id}
|
||||
aria-label={`Title of ${panel.id}`}
|
||||
onChange={(event) =>
|
||||
onChange({ ...panel, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{panel.id}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 text-muted-foreground md:size-8"
|
||||
aria-label={`Remove ${panel.id}`}
|
||||
data-testid={`remove-panel-${panel.id}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No dashboards to assign yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{dashboards.map((dashboard) => {
|
||||
const position = assigned.indexOf(dashboard.name)
|
||||
const id = `assign-${panel.id}-${dashboard.name}`
|
||||
return (
|
||||
<label
|
||||
key={dashboard.name}
|
||||
htmlFor={id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={position >= 0}
|
||||
data-testid={id}
|
||||
onCheckedChange={() => toggle(dashboard.name)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{dashboard.title}</span>
|
||||
{position >= 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{position + 1}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={link}
|
||||
aria-label={`Link for ${panel.id}`}
|
||||
className="text-muted-foreground"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (code.trim()) pair.mutate()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={code}
|
||||
placeholder="Code shown on the screen"
|
||||
aria-label={`Pairing code for ${panel.id}`}
|
||||
autoComplete="off"
|
||||
maxLength={6}
|
||||
data-testid={`pair-code-${panel.id}`}
|
||||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!code.trim() || pair.isPending}
|
||||
data-testid={`pair-${panel.id}`}
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type DashboardDef_Input,
|
||||
DashboardsService,
|
||||
MessagesService,
|
||||
type PanelsConfig,
|
||||
PanelsService,
|
||||
} from "@/client"
|
||||
|
||||
export const dashboardKeys = {
|
||||
@@ -31,6 +33,41 @@ export const dashboardQueryOptions = (name: string, draft = false) => ({
|
||||
queryFn: () => DashboardsService.readDashboard({ name, draft }),
|
||||
})
|
||||
|
||||
export const panelKeys = {
|
||||
all: ["panels"] as const,
|
||||
detail: (id: string) => ["panels", id] as const,
|
||||
}
|
||||
|
||||
/** Every panel and what it shows. Read by the editor to know if a rail is due. */
|
||||
export const panelsQueryOptions = () => ({
|
||||
queryKey: panelKeys.all,
|
||||
queryFn: () => PanelsService.readPanels(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One panel, as the device hanging on the wall reads it.
|
||||
*
|
||||
* A panel credential may read exactly this and the dashboards it names, so
|
||||
* this is the query the panel route is built on rather than the list above.
|
||||
*/
|
||||
export const panelQueryOptions = (id: string) => ({
|
||||
queryKey: panelKeys.detail(id),
|
||||
queryFn: () => PanelsService.readPanel({ panelId: id }),
|
||||
})
|
||||
|
||||
/** Replace the panels. Superuser-only on the server. */
|
||||
export function useSavePanels() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: PanelsConfig) =>
|
||||
PanelsService.savePanels({ requestBody: body }),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(panelKeys.all, saved)
|
||||
queryClient.invalidateQueries({ queryKey: panelKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Every message any flow declares — what a widget can be pointed at. */
|
||||
export const messageCatalogQueryOptions = () => ({
|
||||
queryKey: dashboardKeys.messages,
|
||||
|
||||
@@ -11,6 +11,10 @@ import { slideUp, transitions } from "@/lib/motion"
|
||||
* what the overviews are for, so this bar carries the name and nothing that
|
||||
* takes you away from it. Shared by the flow editor and the dashboards so the
|
||||
* two shells read as one.
|
||||
*
|
||||
* The one exception sits beside it rather than in it: a dashboard assigned to
|
||||
* a panel alongside others is edited with that panel's rail on screen, because
|
||||
* the wall has it too and it takes room off the canvas.
|
||||
*/
|
||||
export function CanvasTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
||||
@@ -39,6 +39,17 @@ const handleApiError = (error: Error) => {
|
||||
return
|
||||
}
|
||||
if (isAuthFailure(error)) {
|
||||
// A paired wall panel has no login screen to go back to — it asks for a
|
||||
// new code instead. Only a 401 is worth throwing its credential away for:
|
||||
// a 403 there is a dashboard it was just unassigned from, which the next
|
||||
// read of the panel corrects on its own.
|
||||
if (window.location.pathname.startsWith("/panel")) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
localStorage.removeItem("access_token")
|
||||
window.location.href = "/panel"
|
||||
}
|
||||
return
|
||||
}
|
||||
if (portal) {
|
||||
// The portal knows whether they are still signed in; it can mint a fresh
|
||||
// handoff or send them to the login screen.
|
||||
|
||||
@@ -15,8 +15,10 @@ import { Route as RecoverPasswordRouteImport } from './routes/recover-password'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as CanvasRouteImport } from './routes/_canvas'
|
||||
import { Route as PanelIndexRouteImport } from './routes/panel.index'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as ViewNameRouteImport } from './routes/view.$name'
|
||||
import { Route as PanelIdRouteImport } from './routes/panel.$id'
|
||||
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutSecretsRouteImport } from './routes/_layout/secrets'
|
||||
@@ -56,6 +58,11 @@ const CanvasRoute = CanvasRouteImport.update({
|
||||
id: '/_canvas',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PanelIndexRoute = PanelIndexRouteImport.update({
|
||||
id: '/panel/',
|
||||
path: '/panel/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -66,6 +73,11 @@ const ViewNameRoute = ViewNameRouteImport.update({
|
||||
path: '/view/$name',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const PanelIdRoute = PanelIdRouteImport.update({
|
||||
id: '/panel/$id',
|
||||
path: '/panel/$id',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
|
||||
id: '/oauth/authorize',
|
||||
path: '/oauth/authorize',
|
||||
@@ -129,7 +141,9 @@ export interface FileRoutesByFullPath {
|
||||
'/secrets': typeof LayoutSecretsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
'/panel/$id': typeof PanelIdRoute
|
||||
'/view/$name': typeof ViewNameRoute
|
||||
'/panel/': typeof PanelIndexRoute
|
||||
'/dashboards/$name': typeof CanvasDashboardsNameRoute
|
||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/dashboards/': typeof LayoutDashboardsIndexRoute
|
||||
@@ -147,7 +161,9 @@ export interface FileRoutesByTo {
|
||||
'/secrets': typeof LayoutSecretsRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
'/panel/$id': typeof PanelIdRoute
|
||||
'/view/$name': typeof ViewNameRoute
|
||||
'/panel': typeof PanelIndexRoute
|
||||
'/dashboards/$name': typeof CanvasDashboardsNameRoute
|
||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/dashboards': typeof LayoutDashboardsIndexRoute
|
||||
@@ -167,8 +183,10 @@ export interface FileRoutesById {
|
||||
'/_layout/secrets': typeof LayoutSecretsRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
'/panel/$id': typeof PanelIdRoute
|
||||
'/view/$name': typeof ViewNameRoute
|
||||
'/_layout/': typeof LayoutIndexRoute
|
||||
'/panel/': typeof PanelIndexRoute
|
||||
'/_canvas/dashboards/$name': typeof CanvasDashboardsNameRoute
|
||||
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/_layout/dashboards/': typeof LayoutDashboardsIndexRoute
|
||||
@@ -188,7 +206,9 @@ export interface FileRouteTypes {
|
||||
| '/secrets'
|
||||
| '/settings'
|
||||
| '/oauth/authorize'
|
||||
| '/panel/$id'
|
||||
| '/view/$name'
|
||||
| '/panel/'
|
||||
| '/dashboards/$name'
|
||||
| '/flows/$flowName'
|
||||
| '/dashboards/'
|
||||
@@ -206,7 +226,9 @@ export interface FileRouteTypes {
|
||||
| '/secrets'
|
||||
| '/settings'
|
||||
| '/oauth/authorize'
|
||||
| '/panel/$id'
|
||||
| '/view/$name'
|
||||
| '/panel'
|
||||
| '/dashboards/$name'
|
||||
| '/flows/$flowName'
|
||||
| '/dashboards'
|
||||
@@ -225,8 +247,10 @@ export interface FileRouteTypes {
|
||||
| '/_layout/secrets'
|
||||
| '/_layout/settings'
|
||||
| '/oauth/authorize'
|
||||
| '/panel/$id'
|
||||
| '/view/$name'
|
||||
| '/_layout/'
|
||||
| '/panel/'
|
||||
| '/_canvas/dashboards/$name'
|
||||
| '/_canvas/flows/$flowName'
|
||||
| '/_layout/dashboards/'
|
||||
@@ -241,7 +265,9 @@ export interface RootRouteChildren {
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
OauthAuthorizeRoute: typeof OauthAuthorizeRoute
|
||||
PanelIdRoute: typeof PanelIdRoute
|
||||
ViewNameRoute: typeof ViewNameRoute
|
||||
PanelIndexRoute: typeof PanelIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -288,6 +314,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof CanvasRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/panel/': {
|
||||
id: '/panel/'
|
||||
path: '/panel'
|
||||
fullPath: '/panel/'
|
||||
preLoaderRoute: typeof PanelIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_layout/': {
|
||||
id: '/_layout/'
|
||||
path: '/'
|
||||
@@ -302,6 +335,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ViewNameRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/panel/$id': {
|
||||
id: '/panel/$id'
|
||||
path: '/panel/$id'
|
||||
fullPath: '/panel/$id'
|
||||
preLoaderRoute: typeof PanelIdRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/oauth/authorize': {
|
||||
id: '/oauth/authorize'
|
||||
path: '/oauth/authorize'
|
||||
@@ -421,7 +461,9 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
OauthAuthorizeRoute: OauthAuthorizeRoute,
|
||||
PanelIdRoute: PanelIdRoute,
|
||||
ViewNameRoute: ViewNameRoute,
|
||||
PanelIndexRoute: PanelIndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
||||
import { LayoutDashboard } from "lucide-react"
|
||||
import { LayoutDashboard, MonitorSmartphone } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { DashboardsService } from "@/client"
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
OverviewToolbar,
|
||||
usePublishAll,
|
||||
} from "@/components/Common/OverviewToolbar"
|
||||
import { PanelsDialog } from "@/components/Dashboard/PanelsDialog"
|
||||
import {
|
||||
dashboardKeys,
|
||||
dashboardsQueryOptions,
|
||||
@@ -22,6 +23,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
@@ -37,6 +43,7 @@ function Dashboards() {
|
||||
const [name, setName] = useState("")
|
||||
const [search, setSearch] = useState("")
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [panelsOpen, setPanelsOpen] = useState(false)
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (dashboard: string) =>
|
||||
@@ -92,6 +99,12 @@ function Dashboards() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Its own root rather than a nested one: which screens exist is a
|
||||
different question from which dashboards do. */}
|
||||
<Dialog open={panelsOpen} onOpenChange={setPanelsOpen}>
|
||||
<PanelsDialog />
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<OverviewToolbar
|
||||
search={search}
|
||||
@@ -103,7 +116,23 @@ function Dashboards() {
|
||||
draftCount={drafts.length}
|
||||
publishing={publishAll.isPending}
|
||||
onPublishAll={() => publishAll.mutate(drafts)}
|
||||
/>
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
aria-label="Panels"
|
||||
data-testid="open-panels"
|
||||
onClick={() => setPanelsOpen(true)}
|
||||
>
|
||||
<MonitorSmartphone />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Panels</TooltipContent>
|
||||
</Tooltip>
|
||||
</OverviewToolbar>
|
||||
<DialogContent>
|
||||
<form
|
||||
className="grid gap-4"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import { PanelRail, RAIL_INSET } from "@/components/Dashboard/PanelRail"
|
||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||
import {
|
||||
dashboardQueryOptions,
|
||||
panelQueryOptions,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* What a paired device shows: the dashboards this panel was assigned.
|
||||
*
|
||||
* The same full-bleed surface as `/view/{name}`, with a rail down the left
|
||||
* when there is more than one to switch between — so a hallway tablet and a
|
||||
* workshop tablet can carry different sets without either dashboard knowing
|
||||
* anything about the other.
|
||||
*
|
||||
* Which one is open lives in the URL, so a panel that reboots comes back where
|
||||
* it was rather than at the first one.
|
||||
*/
|
||||
export const Route = createFileRoute("/panel/$id")({
|
||||
component: PanelRoute,
|
||||
validateSearch: (search: Record<string, unknown>): { d?: string } =>
|
||||
typeof search.d === "string" ? { d: search.d } : {},
|
||||
beforeLoad: async () => {
|
||||
// Unpaired, or a credential the server stopped honouring. Either way this
|
||||
// device needs a new code, not a login form it cannot type into.
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({ to: "/panel" })
|
||||
}
|
||||
},
|
||||
head: ({ params }) => ({ meta: [{ title: `${params.id} - Fluksio` }] }),
|
||||
})
|
||||
|
||||
function PanelRoute() {
|
||||
const { id } = Route.useParams()
|
||||
const { d } = Route.useSearch()
|
||||
useFlowSocket()
|
||||
|
||||
const { data: panel } = useQuery(panelQueryOptions(id))
|
||||
const dashboards = panel?.dashboards ?? []
|
||||
// A name in the URL that this panel no longer carries falls back to the
|
||||
// first, which is what a dashboard removed from under a running panel does.
|
||||
const current = d && dashboards.includes(d) ? d : dashboards[0]
|
||||
|
||||
const { data: dashboard } = useQuery({
|
||||
...dashboardQueryOptions(current ?? ""),
|
||||
enabled: Boolean(current),
|
||||
})
|
||||
const stacked = useIsMobile()
|
||||
const rail = dashboards.length > 1
|
||||
|
||||
if (panel && dashboards.length === 0) {
|
||||
return (
|
||||
<main className="grid h-svh w-full place-items-center p-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No dashboards are assigned to this panel yet.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className={cn(
|
||||
"relative h-svh w-full p-4",
|
||||
stacked ? "overflow-y-auto" : "overflow-hidden",
|
||||
)}
|
||||
style={rail ? { paddingLeft: RAIL_INSET } : undefined}
|
||||
>
|
||||
{rail && current ? (
|
||||
<PanelRail
|
||||
dashboards={dashboards}
|
||||
current={current}
|
||||
linkFor={(name) => ({
|
||||
to: "/panel/$id",
|
||||
params: { id },
|
||||
search: { d: name },
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
{dashboard ? (
|
||||
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
||||
) : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { useEffect } from "react"
|
||||
|
||||
import { PanelsService } from "@/client"
|
||||
|
||||
/**
|
||||
* Adopting a screen that has no keyboard.
|
||||
*
|
||||
* A wall tablet cannot be asked to type an email address and a password, so it
|
||||
* asks for a code instead and shows it. Somebody with an account types that
|
||||
* code into the panels dialog to say which panel this device is, and the
|
||||
* credential it mints arrives here on the next poll.
|
||||
*
|
||||
* Outside both shells like `/view/{name}`: until it is paired, this device has
|
||||
* no session and there is nothing to put around it.
|
||||
*/
|
||||
export const Route = createFileRoute("/panel/")({
|
||||
component: PairPanel,
|
||||
head: () => ({ meta: [{ title: "Pair this panel - Fluksio" }] }),
|
||||
})
|
||||
|
||||
function PairPanel() {
|
||||
// A code lives ten minutes. Asking for one is the mount, and asking again is
|
||||
// what happens when this one is no longer recognised.
|
||||
const start = useMutation({ mutationFn: () => PanelsService.startPairing() })
|
||||
const request = start.mutate
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: asked for once, when the screen goes up.
|
||||
useEffect(() => {
|
||||
request()
|
||||
}, [])
|
||||
|
||||
const started = start.data
|
||||
const { data: status, error } = useQuery({
|
||||
queryKey: ["pairing", started?.code],
|
||||
queryFn: () =>
|
||||
PanelsService.pollPairing({
|
||||
code: started?.code ?? "",
|
||||
secret: started?.secret ?? "",
|
||||
}),
|
||||
enabled: Boolean(started),
|
||||
refetchInterval: 3000,
|
||||
// A 404 means this code is finished, not that the request failed.
|
||||
retry: false,
|
||||
})
|
||||
|
||||
// Expired, or collected already. Ask for another rather than leaving a
|
||||
// number on the wall that no longer works.
|
||||
useEffect(() => {
|
||||
if (error) request()
|
||||
}, [error, request])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.access_token || !status.panel) return
|
||||
localStorage.setItem("access_token", status.access_token)
|
||||
// A full load rather than a route change: everything this page asked for
|
||||
// was asked without a credential, and the socket has to dial again holding
|
||||
// this one.
|
||||
window.location.href = `/panel/${status.panel}`
|
||||
}, [status])
|
||||
|
||||
return (
|
||||
<main className="grid h-svh w-full place-items-center p-8">
|
||||
<div className="grid max-w-md gap-6 text-center">
|
||||
<div className="grid gap-2">
|
||||
<h1 className="text-2xl">Pair this panel</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
On a computer, open <span className="font-medium">Dashboards</span>{" "}
|
||||
→ <span className="font-medium">Panels</span>, pick which panel this
|
||||
is, and enter this code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="font-mono text-6xl font-medium tracking-[0.2em]">
|
||||
<span data-testid="pairing-code" aria-hidden="true">
|
||||
{started?.code ?? "······"}
|
||||
</span>
|
||||
{/* Spelled out, so a reader says the characters rather than trying
|
||||
to pronounce them as a word. */}
|
||||
<span className="sr-only">
|
||||
{(started?.code ?? "").split("").join(" ")}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The code lasts ten minutes; this screen replaces it when it runs out.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,21 +2,22 @@ import { useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
import { PanelSurface } from "@/components/Dashboard/PanelSurface"
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { useFlowSocket } from "@/components/Flow/useFlowSocket"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
|
||||
/**
|
||||
* What a wall panel is pointed at.
|
||||
* What a wall panel is pointed at when it shows one dashboard and nothing else.
|
||||
*
|
||||
* Deliberately outside both shells: no sidebar, no footer, no editing, and no
|
||||
* column cap — the widgets are the whole page. Route splitting means a panel
|
||||
* never downloads the editor or the grid library either.
|
||||
*
|
||||
* A device that was paired to a panel uses `/panel/{id}` instead, which is the
|
||||
* same surface with a rail for switching between the dashboards it was
|
||||
* assigned. This route stays for a browser tab pointed straight at one.
|
||||
*/
|
||||
export const Route = createFileRoute("/view/$name")({
|
||||
component: PanelView,
|
||||
@@ -32,27 +33,19 @@ function PanelView() {
|
||||
const { name } = Route.useParams()
|
||||
useFlowSocket()
|
||||
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
|
||||
// A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
// its size, which reads as nothing at all. Stack it instead.
|
||||
const stacked = useIsMobile()
|
||||
|
||||
if (!dashboard) return <main className="h-svh w-full" />
|
||||
|
||||
if (stacked) {
|
||||
return (
|
||||
<main className="h-svh w-full overflow-y-auto p-4">
|
||||
<DashboardView dashboard={dashboard as Dashboard} stacked />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="h-svh w-full overflow-hidden p-4">
|
||||
{/* The panel's own surface, scaled to whatever screen it landed on. No
|
||||
dots: nothing is being arranged here. */}
|
||||
<CanvasSurface dashboard={dashboard as Dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard as Dashboard} />}
|
||||
</CanvasSurface>
|
||||
<main
|
||||
className={
|
||||
stacked
|
||||
? "h-svh w-full overflow-y-auto p-4"
|
||||
: "h-svh w-full overflow-hidden p-4"
|
||||
}
|
||||
>
|
||||
<PanelSurface dashboard={dashboard as Dashboard} stacked={stacked} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user