Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
341 changes: 341 additions & 0 deletions .github/PROJECT_LAYOUT.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Do not handle domain work inline when a matching agent exists. Inline handling i

## Coding Conventions (high level)

Detailed conventions live in the individual agent files. The non-negotiables for every surface of the app:
Full layout, package boundaries, file-split heuristics, and naming rules live in [PROJECT_LAYOUT.md](PROJECT_LAYOUT.md). Detailed per-domain conventions live in the individual agent files. The non-negotiables for every surface of the app:

### Go

Expand Down
2 changes: 1 addition & 1 deletion backend/internal/handler/routes/refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"io"
"testing"
"time"

Expand Down
102 changes: 102 additions & 0 deletions frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,114 @@ export default tseslint.config(
"no-template-curly-in-string": "error",
"no-promise-executor-return": "error",
"require-atomic-updates": "off", // too many false positives with async effects

// Bulletproof-style feature boundaries. See docs/plans/frontend-features-migration-plan.md.
// Direction: pages/ → features/ → shared/ → app/. Sibling features may only cross via barrels.
"no-restricted-imports": [
"error",
{
patterns: [
{
group: [
"@/features/*/*",
"!@/features/*/index",
"!@/features/admin/_shell",
// Admin sub-feature barrels live at depth 3 (e.g.
// @/features/admin/users). The next pattern enforces no
// *deeper* imports into them.
"!@/features/admin/*",
],
message:
"Import from a feature's public barrel only (e.g. @/features/scanner). Reaching into a feature's internals is forbidden. (features/admin/_shell is the documented exception — admin chrome shared by sub-features.)",
},
{
group: [
"@/features/admin/*/*",
"!@/features/admin/*/index",
"!@/features/admin/_shell/*",
],
message:
"Admin sub-features are opaque to each other. Import from the sub-feature barrel (e.g. @/features/admin/users) only.",
},
],
},
],
},
},
{
// shared/ is the lowest layer — it cannot depend on features/.
files: ["src/shared/**/*.{ts,tsx}"],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/features/*", "@/pages/*"],
message:
"shared/ cannot depend on features/ or pages/. Move the symbol up to the feature, or down into shared/ if it's truly cross-feature.",
},
],
},
],
},
},
{
// Documented debt: the WS layer (shared/) currently dispatches auth +
// scanner actions because both auth and admin features consume the same
// WS client/hook. shared/services/audio/player.ts and shared/types/ws.ts
// also reference scanner-domain types (Call, TranscriptionSegment) which
// logically belong to features/scanner/types.ts. Inverting via callbacks
// and either moving Call to shared/types/ or accepting these as
// permanent type-only imports is tracked as a follow-up; until then
// these specific files may import from the @/features/auth and
// @/features/scanner barrels. Internal-paths into the feature remain
// forbidden.
files: [
"src/shared/services/audio/player.ts",
"src/shared/services/ws/client.ts",
"src/shared/services/ws/client.test.ts",
"src/shared/services/ws/adminClient.ts",
"src/shared/services/ws/adminClient.test.ts",
"src/shared/hooks/useWebSocket.ts",
"src/shared/types/ws.ts",
],
rules: {
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@/features/*/*", "!@/features/*/index"],
message:
"Import from a feature's public barrel only. Reaching into a feature's internals is forbidden.",
},
{
group: [
"@/features/*",
"!@/features/auth",
"!@/features/scanner",
],
message:
"shared/ cannot depend on features/ except @/features/auth and @/features/scanner (WS-layer / Call-type debt — see comment in eslint.config.js).",
},
{
group: ["@/pages/*"],
message: "shared/ cannot depend on pages/.",
},
],
},
],
},
},
{
files: ["src/main.tsx"],
rules: {
"react-refresh/only-export-components": "off",
// main.tsx is the route-wiring file: it pulls in lazy-loaded page
// modules directly to avoid forcing pages through feature barrels
// (which would otherwise create cycles with app/store.ts).
"no-restricted-imports": "off",
},
},
{
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/app/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ import {
type FetchArgs,
type FetchBaseQueryError,
} from "@reduxjs/toolkit/query/react";
import type {
SetupStatus,
RefreshResponse,
LegacyUsageResponse,
} from "@/types";
import type { SetupStatus, LegacyUsageResponse } from "@/types";
import type { RefreshResponse } from "@/features/auth";

const rawBaseQuery = fetchBaseQuery({
baseUrl: "/api/v1",
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/audioListenerMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import type { RootState } from "@/app/store";
import { callReceived } from "@/app/slices/scanner/scannerSlice";
import { audioPlayer } from "@/services/audio/player";
import { callReceived } from "@/features/scanner";
import { audioPlayer } from "@/shared/services/audio/player";

/**
* Listener middleware that bridges incoming Redux call events to the
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/store.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import { api } from "@/app/api";
import { scannerSlice } from "@/app/slices/scanner/scannerSlice";
import { authSlice } from "@/app/slices/shared/authSlice";
import { callsSlice } from "@/app/slices/scanner/callsSlice";
import { scannerSlice } from "@/features/scanner";
import { authSlice } from "@/features/auth";
import { callsSlice } from "@/features/scanner";
import { audioListenerMiddleware } from "@/app/audioListenerMiddleware";

export const store = configureStore({
Expand Down
Empty file added frontend/src/features/.gitkeep
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { configureStore } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
import Admin from "@/pages/Admin";
import { scannerSlice } from "@/app/slices/scanner/scannerSlice";
import { authSlice } from "@/app/slices/shared/authSlice";
import { callsSlice } from "@/app/slices/scanner/callsSlice";
import Admin from "./Admin";
import { scannerSlice } from "@/features/scanner";
import { authSlice } from "@/features/auth";
import { callsSlice } from "@/features/scanner";
import { api } from "@/app/api";
import type { RootState } from "@/app/store";

Expand All @@ -29,7 +29,7 @@ vi.mock("react-router-dom", async () => {

// LegacyUsageBanner mounts on Admin; stub the data hook so it stays empty
// and we don't trigger a real fetch in jsdom.
vi.mock("@/components/admin/LegacyUsageBanner", () => ({
vi.mock("@/features/admin/legacy-usage", () => ({
default: () => null,
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import {
NavigationGuardProvider,
useNavigationGuard,
} from "@/hooks/admin/useNavigationGuard";
} from "./_shell/useNavigationGuard";
import {
Activity,
Users,
Expand All @@ -35,22 +35,22 @@ import {
selectRole,
clearCredentials,
usePostLogoutMutation,
} from "@/app/slices/shared/authSlice";
import { useAdminWebSocket } from "@/hooks/admin/useAdminWebSocket";
import UsersPanel from "@/components/admin/UsersPanel";
import SystemsPanel from "@/components/admin/SystemsPanel";
import GroupsTagsPanel from "@/components/admin/GroupsTagsPanel";
import ApiKeysPanel from "@/components/admin/ApiKeysPanel";
import DirMonitorPanel from "@/components/admin/DirMonitorPanel";
import DownstreamsPanel from "@/components/admin/DownstreamsPanel";
import OptionsPanel from "@/components/admin/OptionsPanel";
import LogsPanel from "@/components/admin/LogsPanel";
import ToolsPanel from "@/components/admin/ToolsPanel";
import WebhooksPanel from "@/components/admin/WebhooksPanel";
import ActivityPanel from "@/components/admin/ActivityPanel";
import SharedLinksPanel from "@/components/admin/SharedLinksPanel";
import TranscriptionPanel from "@/components/admin/TranscriptionPanel";
import LegacyUsageBanner from "@/components/admin/LegacyUsageBanner";
} from "@/features/auth";
import { useAdminWebSocket } from "./_shell/useAdminWebSocket";
import UsersPanel from "@/features/admin/users";
import SystemsPanel from "@/features/admin/systems";
import GroupsTagsPanel from "@/features/admin/groups-tags";
import ApiKeysPanel from "@/features/admin/api-keys";
import DirMonitorPanel from "@/features/admin/dir-monitor";
import DownstreamsPanel from "@/features/admin/downstreams";
import OptionsPanel from "@/features/admin/options";
import LogsPanel from "@/features/admin/logs";
import ToolsPanel from "@/features/admin/tools";
import WebhooksPanel from "@/features/admin/webhooks";
import DashboardsPanel from "@/features/admin/dashboards";
import SharedLinksPanel from "@/features/admin/shared-links";
import TranscriptionPanel from "@/features/admin/transcription";
import LegacyUsageBanner from "@/features/admin/legacy-usage";

const navItems = [
{ to: "/admin/activity", label: "Activity", icon: Activity },
Expand Down Expand Up @@ -203,7 +203,7 @@ export default function Admin() {
<main className="flex-1 p-6 max-w-300 w-full mx-auto">
<LegacyUsageBanner />
<Routes>
<Route path="activity" element={<ActivityPanel />} />
<Route path="activity" element={<DashboardsPanel />} />
<Route path="users" element={<UsersPanel />} />
<Route path="systems" element={<SystemsPanel />} />
<Route path="groups" element={<GroupsTagsPanel />} />
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/features/admin/_shell/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Internal barrel for the admin chrome (slice, hooks, providers).
// Sub-features under features/admin/<x>/ import from here. The leading
// underscore marks this as not-a-feature-itself; sibling sub-features
// may import _shell/, but _shell/ may not import sibling sub-features.
export * from "./adminSlice";
export * from "./useAdminWebSocket";
export * from "./useAdminWsOps";
export * from "./useNavigationGuard";
export * from "./useWsQuery";
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useCallback } from "react";
import { useAppDispatch, useAppSelector } from "@/app/store";
import { adminWsClient } from "@/services/ws/adminClient";
import { setCredentials, usePostRefreshMutation } from "@/app/slices/shared/authSlice";
import { adminWsClient } from "@/shared/services/ws/adminClient";
import { setCredentials, usePostRefreshMutation } from "@/features/auth";
import { api } from "@/app/api";

export function useAdminWebSocket(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useWsQuery, useWsMutation, useLazyWsQuery } from "@/hooks/admin/useWsQuery";
import { useWsQuery, useWsMutation, useLazyWsQuery } from "./useWsQuery";
import type {
AdminUser,
AdminSystem,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { adminWsClient } from "@/services/ws/adminClient";
import { adminWsClient } from "@/shared/services/ws/adminClient";

// ─── useWsQuery ─────────────────────────────────────────────────────────────

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import userEvent from "@testing-library/user-event";
import { configureStore } from "@reduxjs/toolkit";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
import ApiKeysPanel from "@/components/admin/ApiKeysPanel";
import { scannerSlice } from "@/app/slices/scanner/scannerSlice";
import { authSlice } from "@/app/slices/shared/authSlice";
import { callsSlice } from "@/app/slices/scanner/callsSlice";
import ApiKeysPanel from "./ApiKeysPanel";
import { scannerSlice } from "@/features/scanner";
import { authSlice } from "@/features/auth";
import { callsSlice } from "@/features/scanner";
import { api } from "@/app/api";
import type { AdminApiKey, AdminSystem } from "@/types";

Expand Down Expand Up @@ -60,7 +60,7 @@ const deleteApiKeyMutate = vi.fn((_arg: unknown) => ({
unwrap: deleteApiKeyUnwrap,
}));

vi.mock("@/hooks/admin/useAdminWsOps", () => ({
vi.mock("@/features/admin/_shell", () => ({
useListApiKeysQuery: () => ({ data: mockKeys, isLoading: false }),
useListSystemsQuery: () => ({ data: mockSystems, isLoading: false }),
useGetConfigQuery: () => ({ data: { settings: [] }, isLoading: false }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
useDeleteApiKeyMutation,
useListSystemsQuery,
useGetConfigQuery,
} from "@/hooks/admin/useAdminWsOps";
} from "@/features/admin/_shell";
import type { AdminApiKey } from "@/types";

// ─── Form state ───
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin/api-keys/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./ApiKeysPanel";
11 changes: 11 additions & 0 deletions frontend/src/features/admin/dashboards/DashboardsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Dashboards panel — currently a thin wrapper around ActivityPanel.
// When the TR-MQTT integration lands (see docs/plans/tr-mqtt-plan.md),
// this becomes a sub-tab chrome (DaisyUI 5 tabs) hosting ActivityPanel
// and the new TrMqttPanel side-by-side. Keeping the wrapper now means
// that future change is purely additive — no route rename, no folder
// regroup.
import ActivityPanel from "./activity/ActivityPanel";

export default function DashboardsPanel() {
return <ActivityPanel />;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useAdminActivity } from "@/hooks/admin/useAdminActivity";
import type { ChartBucket } from "@/app/slices/admin/activitySlice";
import { useAdminActivity } from "./useAdminActivity";
import type { ChartBucket } from "./activitySlice";
import {
Activity,
Clock3,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { adminWsClient } from "@/services/ws/adminClient";
import { adminWsClient } from "@/shared/services/ws/adminClient";
import type {
ActivityStats,
ActivityChartResponse,
TopTalkgroupsResponse,
} from "@/app/slices/admin/activitySlice";
} from "./activitySlice";

const REFRESH_INTERVAL = 30_000;
const DEBOUNCE_MS = 3_000; // debounce rapid call bursts
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin/dashboards/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./DashboardsPanel";
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useDeleteDirMonitorMutation,
useListSystemsQuery,
useListTalkgroupsQuery,
} from "@/hooks/admin/useAdminWsOps";
} from "@/features/admin/_shell";
import type { AdminDirMonitor } from "@/types";

const DIRMONITOR_TYPES = [
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin/dir-monitor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./DirMonitorPanel";
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
useUpdateDownstreamMutation,
useDeleteDownstreamMutation,
useListSystemsQuery,
} from "@/hooks/admin/useAdminWsOps";
} from "@/features/admin/_shell";
import type { AdminDownstream } from "@/types";

interface DownstreamFormState {
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin/downstreams/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./DownstreamsPanel";
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
useCreateTagMutation,
useUpdateTagMutation,
useDeleteTagMutation,
} from "@/hooks/admin/useAdminWsOps";
} from "@/features/admin/_shell";
import type { AdminGroup, AdminTag } from "@/types";

// ─── Generic label CRUD table ───
Expand Down
1 change: 1 addition & 0 deletions frontend/src/features/admin/groups-tags/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./GroupsTagsPanel";
5 changes: 5 additions & 0 deletions frontend/src/features/admin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Public barrel for the admin feature. The Admin page is imported
// directly by main.tsx via @/features/admin/Admin to avoid circular
// eager-load through @/app/store. This barrel exists for symmetry and
// future cross-feature exports.
export {};
Loading
Loading