Dashboard Widget 추가
App 소유 Dashboard Widget과 renderer를 공개하고 Resource·데이터·다국어·권한 계약이 함께 맞는지 검증합니다.
권한이 있는 조회를 바탕으로 사용자가 배치할 App 위젯을 공개합니다. People의 people.my_assignments를 따라 기존 고용 표시 컴포넌트를 재사용합니다.
네 연결점 구성
- Resource 만들기에서 준비한 Resource와 보호된 조회 API를 사용합니다.
DashboardWidgetContribution에서 언어에 독립적인 component spec과 query reference를 담은DashboardWidget을 반환합니다.- 연결되는
DashboardQuerySource에 권한·대상 집단과 전체 모델 무효화 목록 또는pollingOnly: true를 선언합니다. 실제 조회 endpoint도 매 요청을 허가해야 합니다. - App 진입점에 spec의 렌더러 키를 등록하고 discovery를 갱신합니다. 프런트엔드는 App 패키지 만들기의 빌드 절차로 반영합니다.
결과 확인
권한 있는 사용자가 위젯을 배치하면 해당 범위의 최신 데이터가 보이고, 권한을 회수하면 데이터 경로가 차단되어야 합니다. 로딩·빈 결과·오류·locale 처리는 App 테스트하기로 확인합니다.
공개 Resource를 조합한 표가 목적이라면 아래 데이터 위젯 빌더로 충분할 수 있습니다. 이어지는 구현은 별도 표현이 필요한 위젯에 사용하세요.
별도 Widget이 필요 없으면 저장한 Report 재사용
선언한 Resource 데이터를 재사용하려고 App 전용 Report API나 permission을 만들지 마세요. Shell의 Report library와 Dashboard builder는 같은 Core composition 계약을 사용합니다. Report는 정의와 immutable revision을 소유하고 Dashboard 배치는 선택한 revision을 고정한 채 layout만 소유합니다. 어느 화면에서 실행해도 현재 viewer를 다시 authorize하므로 Report 공유가 App 원본 데이터 permission을 주지 않습니다. Core가 enabled한 경우 Agent 호출도 같은 pinned-revision query 계약을 사용합니다.
1. Widget contribution 공개
DashboardWidgetContribution을 구현합니다. 이 인터페이스는
ResourceCatalogContribution을 상속하므로 widget을 반환하기 전에 안정된
Resource Key와 model을 함께 바인딩해야 합니다.
packages/people/src/Contribution/PeoplePersonalDashboardWidgets.php의 실제
계약을 widget 하나로 펼치면 다음과 같습니다.
<?php
namespace Amuzcorp\Nexia\PeopleCore\Contribution;
use Amuzcorp\Nexia\PeopleCore\Models\Worker;
use Nexia\Dashboard\DashboardWidget;
use Nexia\Dashboard\Contracts\DashboardWidgetContribution;
use Nexia\Dashboard\DashboardWidgetKind;
use Nexia\Dashboard\RendererCapability;
final class PeoplePersonalDashboardWidgets implements DashboardWidgetContribution
{
private const TOOL = 'people.worker_profile.read';
public static function resourceKey(): string
{
return 'people.worker';
}
public static function resourceModelClass(): string
{
return Worker::class;
}
public static function dashboardWidgets(): array
{
return [
new DashboardWidget(
key: 'people.my_assignments',
titleKey: 'people.worker_profile.assignments.title',
description: 'Current Operating Unit placements, position details, and reporting line.',
spec: [
'component' => 'people.EmploymentSection',
'props' => [
'titleKey' => 'people.worker_profile.assignments.title',
'queryRef' => [
'tool' => self::TOOL,
'args' => [],
'refresh' => '60s',
],
'section' => 'assignments',
],
],
kind: DashboardWidgetKind::Table,
capability: RendererCapability::HumanAndAgent,
),
];
}
}key는 App prefix를 가진 영구 식별자입니다. titleKey는 locale catalog
key이고 description은 widget 선택을 위한 기술 설명이므로 영문 literal입니다.
spec에는 renderer와 query 지시만 있고 조회된 row는 없습니다. Core가 요청
경계에서 제목을 지역화하고 데이터를 해석합니다.
실제 클래스는 반복되는 widget 생성을 private helper로 묶습니다. 위 코드는 widget 하나가 가져야 할 계약을 완전한 형태로 펼친 것입니다.
2. 권한이 적용된 query source 공개
queryRef.tool은 실제 Dashboard query source로 해석되어야 합니다.
packages/people/src/Contribution/PeoplePersonalDashboardQuerySources.php는
다음 source를 공개합니다.
<?php
declare(strict_types=1);
namespace Amuzcorp\Nexia\PeopleCore\Contribution;
use Amuzcorp\Nexia\PeopleCore\Models\Employment;
use Amuzcorp\Nexia\PeopleCore\Models\EmploymentCategory;
use Amuzcorp\Nexia\PeopleCore\Models\EmploymentContract;
use Amuzcorp\Nexia\PeopleCore\Models\Grade;
use Amuzcorp\Nexia\PeopleCore\Models\Job;
use Amuzcorp\Nexia\PeopleCore\Models\Position;
use Amuzcorp\Nexia\PeopleCore\Models\PositionVersion;
use Amuzcorp\Nexia\PeopleCore\Models\Worker;
use Amuzcorp\Nexia\PeopleCore\Models\WorkerAssignment;
use Amuzcorp\Nexia\PeopleCore\Models\WorkerRelationship;
use Nexia\Dashboard\DashboardQuerySource;
use Nexia\Dashboard\Contracts\DashboardQuerySourceContribution;
use Nexia\Dashboard\RendererCapability;
use Nexia\Permission\SubjectPopulation;
final class PeoplePersonalDashboardQuerySources implements DashboardQuerySourceContribution
{
public static function dashboardQuerySources(): array
{
return [
new DashboardQuerySource(
key: 'people.worker_profile.read',
description: 'Signed-in worker employment summary across the worker’s own assignments.',
path: '/api/people/me',
permission: 'people.worker_profile.read',
subjectPopulation: SubjectPopulation::Self,
invalidatedModels: [
Worker::class,
WorkerRelationship::class,
Employment::class,
EmploymentContract::class,
WorkerAssignment::class,
Position::class,
PositionVersion::class,
EmploymentCategory::class,
Job::class,
Grade::class,
],
capability: RendererCapability::HumanAndAgent,
),
];
}
}HTTP endpoint에는 자기 backend Policy가 여전히 필요합니다. Dashboard permission은
가용성과 발견을 통제할 뿐 Route authorization을 대신하지 않습니다.
SubjectPopulation::Self도 중요한 계약입니다. 브라우저가 고른 임의 worker가
아니라 로그인한 worker 자신을 설명합니다.
projection을 무효화해야 하는 model도 빠짐없이 나열하세요. 누락해도 최초 응답의 권한이 바뀌는 것은 아니지만 해당 model이 변경된 뒤 위젯이 오래된 상태로 남을 수 있습니다.
3. Frontend renderer 등록
PHP spec의 component 이름과 frontend registry key는 정확히 같아야 합니다. Core 별칭이 아니라 SDK host 계약을 사용해 App entry에 renderer를 등록합니다.
import { registerLazyAgentComponent } from "@nexia/sdk/host";
registerLazyAgentComponent(
"people.EmploymentSection",
() => import("./profile/PeopleEmploymentSectionDashboardRenderer")
.then((module) => ({
default: module.PeopleEmploymentSectionDashboardRenderer,
})),
);실제 renderer는 AgentComponentRendererProps를 받고 spec.props를 해석한 다음,
선언된 query reference를 /agent/tool-data로 조회합니다. 화면은 People의 다른
위치에서도 쓰는 EmploymentSection을 재사용합니다. 이렇게 해야 Dashboard만을
위한 두 번째 고용 데이터 해석이 생기지 않습니다.
호스트의 공용 data lifecycle을 사용하고 auto-height ref는 widget의 scroll
container에 붙입니다. 아래 hook 발췌는 renderer 컴포넌트 내부 코드입니다. queryRef, preview, enabled, queryKey, queryFn, spec은 props 검증과 조회 adapter에서 준비합니다.
import { useQuery } from "@tanstack/react-query";
import { parseRefreshHint } from "@nexia/sdk";
import {
useAgentToolDataFreshness,
useAgentToolDataInvalidation,
useDashboardWidgetAutoHeight,
} from "@nexia/sdk/host";
const refreshMs = parseRefreshHint(queryRef.refresh);
useAgentToolDataInvalidation(!preview);
const freshness = useAgentToolDataFreshness({ enabled, refreshMs, preview });
const result = useQuery({ queryKey, queryFn, enabled, ...freshness });
const autoHeightRef = useDashboardWidgetAutoHeight(spec.props.widgetId);
return <div ref={autoHeightRef} className="flex h-full min-h-0 flex-col overflow-auto">…</div>;호스트는 빠른 hint를 Dashboard 전체 최소 cadence인 10분으로 제한하고 더 느린
hint는 존중하며, 둘러싼 Work Tab이 parked 상태이면 timer를 멈춥니다.
useAgentToolDataInvalidation은 호환용 no-op이고 실제 무효화는 Shell 전체 Reverb
ingress 하나가 소유합니다. auto-height ref는 board 밖에서 아무 동작도 하지 않고
새로 추가된 board cell이 자리 잡을 때만 관여합니다. 나중의 데이터 변화가 사용자가
이미 배치한 widget을 다시 배열하지는 않습니다.
데이터 조회는 adapter에, 도메인 표시는 재사용 가능한 component에 두세요. 그러면 Profile Slot과 Dashboard가 서로 다른 host props 계약을 지키면서 같은 표현을 공유할 수 있습니다.
4. 발견 정보 갱신과 실행
Contribution class는 생성된 App map으로 발견됩니다. 새 contributor class를 추가했으면 map과 runtime cache를 갱신하고 App frontend asset을 다시 만듭니다.
task artisan -- nexia-runtime:generate-app-map
task artisan -- nexia-runtime:refresh-runtime-caches
task artisan -- nexia-apps:activate-package-app people --build-assets최종 catalog entry의 key는 people.my_assignments, renderer는
people.EmploymentSection, data source는 people.worker_profile.read여야 합니다.
조회된 row를 spec에 직접 넣지 마세요. discovery 결과가 요청별 데이터가 되고
query-source 경계를 우회하게 됩니다.
검증
Dashboard registry와 family resolution의 focused contract test를 실행합니다.
docker compose exec -T app vendor/bin/pest tests/Unit/Dashboard/DashboardWidgetRegistryTest.php tests/Unit/Dashboard/PersonalWidgetFamilyResolverTest.php --compact그다음 실제 렌더링 경로를 확인합니다.
| 확인 항목 | 동작 | 기대 결과 |
|---|---|---|
| 발견 | Dashboard widget picker 열기 | 권한 있는 행위자에게 people.my_assignments가 보임 |
| 지역화 | en, ko, zh 전환 | 제목이 바뀌고 catalog key가 그대로 보이지 않음 |
| 데이터 | Widget 배치 | people.worker_profile.read에서 배치 정보가 나옴 |
| Parked tab | 다른 Work Tab으로 전환한 뒤 network activity 확인 | 상태는 유지하지만 다시 보일 때까지 지속 polling이 멈춤 |
| 최초 크기 | Board에 widget 추가 | 새 cell이 자연 콘텐츠 높이에 맞춰 자리 잡고 기존 배치는 나중에 자동 resize되지 않음 |
| 권한 회수 | read grant 제거 후 새로고침 | 행이 노출되지 않고 widget 또는 data path가 거부됨 |
| 렌더링 | 브라우저 console 확인 | people.EmploymentSection unknown-component 오류 없음 |
흔한 실수
Widget은 보이는데 unknown component로 실패. PHP의 spec.component와 frontend
registry key가 다르거나 App entry가 로드되지 않았습니다. 두 문자열을 정확히
비교하고 asset을 다시 만드세요.
Renderer가 hard-coded sample data에서만 동작. 컴포넌트 preview는 query
계약이 아닙니다. DashboardQuerySource를 공개하고 queryRef.tool을 그 key에
연결하며 backing endpoint의 authorization을 유지하세요.
번역 문구를 spec에 직접 기록. Spec은 모든 locale이 공유합니다. catalog
key를 전달하고 Core가 payload 경계에서 문구를 구체화하게 하세요.
Contributor가 model만 언급하고 Resource를 바인딩하지 않음.
DashboardWidgetContribution은 ResourceCatalogContribution을 상속합니다.
resourceKey()와 resourceModelClass()를 모두 구현해야 합니다.
Dashboard permission을 backend enforcement로 취급. 발견과 가시성은 authorization이 아닙니다. API 또는 tool path도 Tenant 안에서 요청자를 확인하고 자기 Policy를 강제해야 합니다.
리포트용 데이터 공개
Dashboard 편집기는 App 위젯과 저장된 리포트 참조를 분리합니다.
| 탭 | 하는 일 |
|---|---|
| 위젯 카탈로그 | Core 또는 App이 contribution한 준비된 위젯을 배치합니다. Contribution 위젯은 Resource에 묶이지만 전용 query와 renderer를 쓸 수 있으므로 “단일 Resource 빌더”라는 뜻은 아닙니다. |
| 리포트 | 접근 가능한 저장 리포트 리비전을 추가합니다. 분석 생성과 편집은 독립 리포트 작업 공간에서 합니다. |
리포트 작성은 사람과 Agent가 같은 server 검증 composition·presentation 계약으로 사용합니다. App 간 의존성이나 Dashboard 전용 permission을 추가하지 않고 기존 Resource 선언을 재사용합니다.
App 개발자가 공개해야 하는 것
Resource가 리포트 작성에 나타나고 제한된 graph에 연결되게 하려면 다음을 모두 갖추세요.
- 번역되는
labelKey와publicForBuilder: true를 가진 activeResourceDescriptor를 공개합니다. - Owner App을 operational 상태로 유지하고, Core가 각 source query를 독립적으로 권한 검사할 수 있도록 standard SQL authorization profile을 사용합니다.
public_id는 Resource identity로 유지하고, 안전한 업무 field마다fieldSchema에label_key와 필요한composition.selectable,groupable, aggregation, time bucket을 명시합니다. 해석되는 label이 없는 field는 빌더에서 사용할 수 없습니다.- Model의
resourceListFields()에 지원할Exactfilter와 catalog label key를 선언합니다. Filter와 출력은 독립적이므로 field를 표시하려면 composition metadata도 명시해야 합니다. Enum은 raw 값과enum_labels를 공개해 번역된 선택지를 제공합니다. - 기존 Resource 계약으로 relationship의 근거를 공개합니다. Core는 현재 결과 shape와
비용 limit에서 증명할 수 있는 제한된 graph만 선택하게 합니다.
- Row 결과는 accepted target Resource key와 일치하는 storage topology를 가진 직접
resource_referencefield를 선언합니다. - Party 기준 count는 두 Resource 모두 canonical
party_public_id를 공개하고, 증명 가능한 Party foreign-key topology를 가져야 합니다.
- Row 결과는 accepted target Resource key와 일치하는 storage topology를 가진 직접
Builder는 결과 shape와 현재 catalog limit에서 Core가 안전성을 증명할 수 있는 제한된 graph만 사용하며 임의 join을 추론하지 않습니다.
표준 Resource query로 표현할 수 없는 App 소유 scope, field redaction, 안전한
projection이 있으면 CompositionQueryContribution을 구현하고 provider에서
AuthorizedCompositionQuery를 반환합니다. Core는 actor, request, Resource key,
정확한 조직 target을 복원합니다. Provider는 이미 인가된 Eloquent query와 Core가
select/filter/bind/group할 수 있는 field alias만 반환합니다. 기존 read permission과
row predicate를 재사용하며 Dashboard permission, client SQL, chart 전용 App endpoint를
만들지 않습니다.
Core가 이 계약에서 Resource와 relationship catalog를 파생하고 현재 actor에 맞게 거릅니다. App 쌍 join 선언, cross-App import, reporting table, model 이름, SQL 식, 물리 column을 public contract에 추가하지 마세요. Resource 만들기에서 시작하고, Resource 계약에서 정확한 대상 조건을 확인한 뒤, row relationship에는 Resource Reference를 사용하세요.
브라우저는 서버가 소유하는 세 단계를 따릅니다.
GET /api/dashboards/resource-composition/catalog에서 현재 actor가 발견할 수 있는 Resource, 서버가 발급한 relationship, 현재 field·filter·row·bucket· timeout 제한을 받습니다.POST /api/dashboards/resource-composition/preview에 명시적legal_entity_public_id, 선택적operating_unit_public_id, 엄격한composition_spec을 보냅니다. 응답에는 정규화된 spec, descriptor fingerprint, 서버가 발급한 output column, 그리고shape,rows,meta를 갖는 제한된result가 들어 있습니다.- Preview는
Table, metric, bar, line, donut, pivot 적합성을 반환하고,/api/dashboards/data-widgets에 유효한 presentation mapping을 저장합니다. 이미 배치한 Dashboard는 자기 component snapshot을 보유하므로 재사용 항목을 삭제해도 기존 배치가 지워지지 않습니다.
CompositionSpec은 의미적 의도만 전달하며 하나의 현재 schema인
schema_version: 1을 사용합니다. 제한된 source alias, 서버가 발급한 불투명
relationship, 각 relationship의 명시적 source·target·match 동작, 필수 population semantics를
지정합니다. Row 결과에는 row_source도 필수입니다. Core는 planning 전에 현재 catalog에서
endpoint, topology, authorization, 결과 grain, 비용을 검증합니다. Spec은 field, typed
where predicate, dimension, measure, rows 또는 aggregate만 선택하며 table, model,
SQL, join, output path를 받지 않습니다. 한도와 field는 현재 catalog에서 읽으세요.
표시 열과 필터의 의미
표시 열은 결과 Table에 반환할 column을 뜻합니다. Relationship을 고르거나 filter를 정의하는 항목이 아닙니다. Host는 Resource identity를 포함하고 Resource 또는 authorized provider가 selectable로 선언한 현재 catalog field만 허용합니다. 공개한 label과 reference decoration을 사용하며 raw SQL alias를 노출하거나 filter 가능성을 출력 권한으로 바꾸지 않습니다.
필터는 composition 전에 owner Resource의 권한 적용 query를 제한합니다.
resourceListFields()가 지원하는 Exact 항목만 eq/in filter가 됩니다. Descriptor의
enum과 enum_labels는 번역된 단일·다중 선택지로, boolean field는 예·아니요 선택지로,
날짜·시간·숫자의 단일 값은 해당 native input type으로 표시됩니다. 제약 없는 문자열,
UUID, 공개된 선택지가 없는 다중 값은 계속 직접 입력합니다. Filter할 수 있다는 사실만으로
그 field 값이 결과에 노출되지는 않습니다.
재사용 data widget은 정규화된 composition과 presentation snapshot을 저장합니다. 현재 presentation 계약은 의도적으로 작습니다.
{
"component": "Table",
"title": "Party별 배정",
"columns": [{ "key": "dimension_label", "label": "Party" }],
"pageSize": 25,
"paginate": true
}Column key와 순서는 preview의 output_columns와 정확히 일치해야 하고 format은
서버 projection에서 파생됩니다. 저장 항목은 owner 범위이며 ready,
needs_repair, source_unavailable 상태를 보고합니다. Catalog 발견, preview,
저장, Dashboard 직렬화, 모든 live query는 actor, 명시적 조직 target, source App
lifecycle, preview에서 받은 descriptor fingerprint가 정확히 일치하는지 먼저 확인한
뒤 relationship, field visibility, row authorization을
다시 확인합니다. App이 비활성화되거나 descriptor가 바뀌거나 permission이 회수되면
예전 query를 재생하지 않고 닫힘 방향으로 실패합니다.
공개 descriptor field를 추가하거나 변경하면 이 fingerprint도 바뀔 수 있습니다.
그 경우 저장한 widget은 needs_repair가 되며, 저장 snapshot은 보존한 채 다시
preview한 다음 field 또는 presentation 선택을 복구해야 다시 실행할 수 있습니다.
이번 릴리스는 저장 widget을 자동 마이그레이션하지 않습니다.
구현 anchor는 대상 조건과 public field를 소유하는
app/ResourceComposition/SemanticCatalog.php, relationship 증명을 소유하는
app/ResourceComposition/RelationshipResolver.php, live authorization과 query plan을
소유하는 app/ResourceComposition/CompositionPlanner.php입니다.