# Three-Panel Web Restructure And Handler Migration
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restructure each app's UI into three logical panels (platform-config / app-config / ops), move stateless handlers (log, doc) to the shared core, and split API.md per application.
**Architecture:** Each app gets three tabs: "运维" (ops view), "应用配置" (app-specific config), "平台配置" (shared platform config). Core HTML pages are cleaned of unit references. Feeder overrides core pages that need unit-related UI via the ServeDir fallback chain. Stateless handlers (log, doc) move to `plc_platform_core::handler` so both apps can register them. Each app serves its own API doc. Handlers that depend on `AppState` (source, point, equipment, tag, page) stay in the feeder app — they require the `PlatformContext` refactor to move, which is deferred to a follow-up plan.
**Tech Stack:** Rust (Axum, tower-http), HTML/CSS/JS (vanilla ES modules), Cargo workspace
---
## File Map
### Core HTML changes (remove unit references)
- Modify: `web/core/html/source-panel.html` — remove unit list section, keep data source only
- Modify: `web/core/html/equipment-panel.html` — remove batch-unit toolbar
- Modify: `web/core/html/modals.html` — remove unit select from equipment modal
### Feeder HTML overrides (add unit references back)
- Create: `web/feeder/html/source-panel.html` — override with units + sources stacked
- Create: `web/feeder/html/equipment-panel.html` — override with unit batch toolbar
- Create: `web/feeder/html/modals.html` — override with unit select in equipment modal
- Create: `web/feeder/html/unit-panel.html` — standalone unit config panel for app-config view
### Feeder three-tab UI
- Modify: `web/feeder/html/topbar.html` — add third tab
- Modify: `web/feeder/index.html` — add unit-panel partial, add grid-app-config layout
- Modify: `web/feeder/js/app.js` — three-way view switching
- Modify: `web/feeder/js/dom.js` — add tabAppConfig selector
- Modify: `web/core/styles.css` — add grid-app-config layout
### Handler migration to core
- Create: `crates/plc_platform_core/src/handler.rs` — module declarations
- Create: `crates/plc_platform_core/src/handler/log.rs` — moved from feeder
- Create: `crates/plc_platform_core/src/handler/doc.rs` — generic markdown serving
- Modify: `crates/plc_platform_core/src/lib.rs` — export handler module
- Modify: `crates/plc_platform_core/Cargo.toml` — add async-stream if needed
- Modify: `crates/app_feeder_distributor/src/handler.rs` — replace log/doc with re-exports
- Modify: `crates/app_feeder_distributor/src/handler/doc.rs` — thin wrapper calling core
- Delete: `crates/app_feeder_distributor/src/handler/log.rs` — replaced by core
### Ops app handler registration
- Modify: `crates/app_operation_system/src/lib.rs` — add handler module
- Create: `crates/app_operation_system/src/handler.rs` — module declarations
- Create: `crates/app_operation_system/src/handler/doc.rs` — ops-specific doc handler
- Modify: `crates/app_operation_system/src/router.rs` — register log/doc routes
- Modify: `crates/app_operation_system/Cargo.toml` — add needed deps
### API.md split
- Rename: `API.md` → `docs/api-feeder.md`
- Create: `docs/api-ops.md` — ops API (health endpoint for now)
---
## Task 1: Clean Core HTML — Remove Unit References
**Files:**
- Modify: `web/core/html/source-panel.html`
- Modify: `web/core/html/equipment-panel.html`
- Modify: `web/core/html/modals.html`
- [ ] **Step 1: Rewrite core source-panel to data sources only**
Replace `web/core/html/source-panel.html` with:
```html
```
- [ ] **Step 2: Remove batch-unit toolbar from core equipment-panel**
Replace `web/core/html/equipment-panel.html` with:
```html
```
- [ ] **Step 3: Remove unit select from core equipment modal**
In `web/core/html/modals.html`, remove the unit select label from the equipment modal. Replace the equipment modal section (lines 1-35) with:
```html
```
Keep the rest of modals.html (point, source, binding modals) unchanged.
- [ ] **Step 4: Commit**
```bash
git add web/core/html/source-panel.html web/core/html/equipment-panel.html web/core/html/modals.html
git commit -m "refactor(web): remove unit references from core HTML pages"
```
## Task 2: Create Feeder Override Pages With Unit References
**Files:**
- Create: `web/feeder/html/source-panel.html`
- Create: `web/feeder/html/equipment-panel.html`
- Create: `web/feeder/html/modals.html`
- [ ] **Step 1: Create feeder source-panel override (units + sources stacked)**
`web/feeder/html/source-panel.html` — same as the original file with both sections:
```html
```
- [ ] **Step 2: Create feeder equipment-panel override (with unit batch toolbar)**
`web/feeder/html/equipment-panel.html` — same as original with batch-unit toolbar:
```html
```
- [ ] **Step 3: Create feeder modals override (with unit select in equipment modal)**
`web/feeder/html/modals.html` — copy of the ORIGINAL core modals.html (before Task 1 removes the unit select). This file overrides the core version via the ServeDir fallback chain. The equipment modal retains the unit select field that Task 1 removes from core.
Write the full file with this content (identical to the original core modals.html):
```html
```
- [ ] **Step 4: Commit**
```bash
git add web/feeder/html/source-panel.html web/feeder/html/equipment-panel.html web/feeder/html/modals.html
git commit -m "refactor(web): create feeder overrides for unit-dependent pages"
```
## Task 3: Create Feeder Unit Panel And Add Three-Tab UI
**Files:**
- Create: `web/feeder/html/unit-panel.html`
- Modify: `web/feeder/html/topbar.html`
- Modify: `web/feeder/index.html`
- Modify: `web/feeder/js/dom.js`
- Modify: `web/feeder/js/app.js`
- Modify: `web/core/styles.css`
- [ ] **Step 1: Create standalone unit-panel for app-config view**
`web/feeder/html/unit-panel.html`:
```html
```
Note: Uses separate IDs (`refreshUnitBtn2`, `newUnitBtn2`, `unitConfigList`) to avoid DOM ID conflicts with the unit list in the source-panel override. The JS wiring will connect these to the same handler functions.
- [ ] **Step 2: Update topbar for three tabs**
Replace `web/feeder/html/topbar.html`:
```html
```
- [ ] **Step 3: Replace feeder index.html `` contents to include unit-panel partial**
Replace the entire `` block in `web/feeder/index.html`:
```html
```
- [ ] **Step 4: Add grid-app-config layout to styles.css**
Add after the existing `.grid-ops` rule in `web/core/styles.css`:
```css
.grid-app-config {
display: grid;
gap: 1px;
height: calc(100vh - var(--topbar-h));
grid-template-columns: 1fr;
grid-template-rows: 1fr;
}
.grid-app-config .panel.app-config-main { grid-column: 1; grid-row: 1; }
```
Also add the responsive override inside the existing `@media (max-width: 900px)` block:
```css
.grid-app-config {
grid-template-columns: 1fr;
grid-template-rows: auto;
height: auto;
}
```
- [ ] **Step 5: Add tabAppConfig to dom.js**
In `web/feeder/js/dom.js`, add:
```javascript
tabAppConfig: byId("tabAppConfig"),
refreshUnitBtn2: byId("refreshUnitBtn2"),
newUnitBtn2: byId("newUnitBtn2"),
unitConfigList: byId("unitConfigList"),
```
- [ ] **Step 6: Update app.js for three-way view switching**
Replace the `switchView` function in `web/feeder/js/app.js`:
```javascript
function switchView(view) {
state.activeView = view;
const main = document.querySelector("main");
main.className =
view === "ops" ? "grid-ops" :
view === "app-config" ? "grid-app-config" :
"grid-config";
dom.tabOps.classList.toggle("active", view === "ops");
dom.tabAppConfig.classList.toggle("active", view === "app-config");
dom.tabConfig.classList.toggle("active", view === "config");
// config-only panels (platform config view)
["top-left", "top-right", "bottom-left", "bottom-right"].forEach((cls) => {
const el = main.querySelector(`.panel.${cls}`);
if (el) el.classList.toggle("hidden", view !== "config");
});
const logStreamPanel = main.querySelector(".panel.bottom-mid");
if (logStreamPanel) logStreamPanel.classList.toggle("hidden", view !== "config");
// ops-only panels
const opsMain = main.querySelector(".panel.ops-main");
const opsBottom = main.querySelector(".panel.ops-bottom");
if (opsMain) opsMain.classList.toggle("hidden", view !== "ops");
if (opsBottom) opsBottom.classList.toggle("hidden", view !== "ops");
// app-config-only panels
const appConfigMain = main.querySelector(".panel.app-config-main");
if (appConfigMain) appConfigMain.classList.toggle("hidden", view !== "app-config");
if (view === "config") {
startLogs();
if (!_configLoaded) {
_configLoaded = true;
withStatus((async () => {
await Promise.all([loadSources(), loadEquipments(), loadEvents()]);
await loadPoints();
})());
}
} else {
stopLogs();
}
if (view === "app-config") {
if (!_appConfigLoaded) {
_appConfigLoaded = true;
withStatus(loadUnits());
}
}
}
```
Add `let _appConfigLoaded = false;` alongside the existing `let _configLoaded = false;`.
Update `bindEvents` to add:
```javascript
dom.tabAppConfig.addEventListener("click", () => switchView("app-config"));
dom.refreshUnitBtn2.addEventListener("click", () => withStatus(loadUnits()));
dom.newUnitBtn2.addEventListener("click", openCreateUnitModal);
```
- [ ] **Step 7: Verify no broken partials**
Run feeder locally, check all three tabs load without console errors.
Run:
```bash
cargo check -p app_feeder_distributor
```
Expected: PASS (no Rust changes in this task)
- [ ] **Step 8: Commit**
```bash
git add web/feeder/html/unit-panel.html web/feeder/html/topbar.html web/feeder/index.html web/feeder/js/dom.js web/feeder/js/app.js web/core/styles.css
git commit -m "feat(feeder): add three-tab UI (ops / app-config / platform-config)"
```
## Task 4: Move Log Handler To Core
**Files:**
- Create: `crates/plc_platform_core/src/handler.rs`
- Create: `crates/plc_platform_core/src/handler/log.rs`
- Modify: `crates/plc_platform_core/src/lib.rs`
- Modify: `crates/app_feeder_distributor/src/handler.rs`
- Delete: `crates/app_feeder_distributor/src/handler/log.rs`
- [ ] **Step 1: Create core handler module**
`crates/plc_platform_core/src/handler.rs`:
```rust
pub mod log;
```
- [ ] **Step 2: Move log.rs to core**
Copy `crates/app_feeder_distributor/src/handler/log.rs` to `crates/plc_platform_core/src/handler/log.rs`.
Change the import path from `plc_platform_core::util::response::ApiErr` to `crate::util::response::ApiErr`.
- [ ] **Step 3: Export handler module from core lib.rs**
Add `pub mod handler;` to `crates/plc_platform_core/src/lib.rs`.
- [ ] **Step 4: Update feeder handler.rs to re-export log from core**
In `crates/app_feeder_distributor/src/handler.rs`, replace:
```rust
pub mod log;
```
with:
```rust
pub mod log {
pub use plc_platform_core::handler::log::*;
}
```
Delete `crates/app_feeder_distributor/src/handler/log.rs`.
- [ ] **Step 5: Verify feeder compiles**
Run:
```bash
cargo check -p app_feeder_distributor
```
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add crates/plc_platform_core/src/handler.rs crates/plc_platform_core/src/handler/log.rs crates/plc_platform_core/src/lib.rs crates/app_feeder_distributor/src/handler.rs
git rm crates/app_feeder_distributor/src/handler/log.rs
git commit -m "refactor(core): move log handler to shared platform core"
```
## Task 5: Move Doc Handler To Core And Split API.md
**Files:**
- Create: `crates/plc_platform_core/src/handler/doc.rs`
- Modify: `crates/plc_platform_core/src/handler.rs`
- Modify: `crates/app_feeder_distributor/src/handler.rs`
- Modify: `crates/app_feeder_distributor/src/handler/doc.rs`
- Create: `crates/app_operation_system/src/handler.rs`
- Create: `crates/app_operation_system/src/handler/doc.rs`
- Rename: `API.md` → `docs/api-feeder.md`
- Create: `docs/api-ops.md`
- [ ] **Step 1: Create core generic markdown serving utility**
`crates/plc_platform_core/src/handler/doc.rs`:
```rust
use axum::{
http::{header, HeaderMap, HeaderValue, StatusCode},
response::IntoResponse,
};
use crate::util::response::ApiErr;
pub async fn serve_markdown(path: &str) -> Result {
let content = tokio::fs::read_to_string(path)
.await
.map_err(|err| {
tracing::error!("Failed to read {}: {}", path, err);
ApiErr::NotFound(format!("{} not found", path), None)
})?;
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/markdown; charset=utf-8"),
);
Ok((StatusCode::OK, headers, content))
}
```
- [ ] **Step 2: Add doc to core handler module**
In `crates/plc_platform_core/src/handler.rs`, add:
```rust
pub mod doc;
pub mod log;
```
- [ ] **Step 3: Update feeder doc handler to use core utility**
Replace `crates/app_feeder_distributor/src/handler/doc.rs`:
```rust
use axum::response::IntoResponse;
use plc_platform_core::util::response::ApiErr;
pub async fn get_api_md() -> Result {
plc_platform_core::handler::doc::serve_markdown("docs/api-feeder.md").await
}
pub async fn get_readme_md() -> Result {
plc_platform_core::handler::doc::serve_markdown("README.md").await
}
```
- [ ] **Step 4: Rename API.md to docs/api-feeder.md**
```bash
git mv API.md docs/api-feeder.md
```
- [ ] **Step 5: Create ops API doc**
`docs/api-ops.md`:
```markdown
# 运转系统 API
## 健康检查
- `GET /api/health` — 返回应用名称和状态
## 日志
- `GET /api/logs` — 拉取日志内容
- `GET /api/logs/stream` — SSE 增量推送
## 文档
- `GET /api/docs/api-md` — 获取 API 文档
- `GET /api/docs/readme-md` — 获取 README
```
- [ ] **Step 6: Create ops handler module with doc handler**
`crates/app_operation_system/src/handler.rs`:
```rust
pub mod doc;
```
`crates/app_operation_system/src/handler/doc.rs`:
```rust
use axum::response::IntoResponse;
use plc_platform_core::util::response::ApiErr;
pub async fn get_api_md() -> Result {
plc_platform_core::handler::doc::serve_markdown("docs/api-ops.md").await
}
pub async fn get_readme_md() -> Result {
plc_platform_core::handler::doc::serve_markdown("README.md").await
}
```
Add `pub mod handler;` to `crates/app_operation_system/src/lib.rs`.
- [ ] **Step 7: Verify both apps compile**
Run:
```bash
cargo check -p app_feeder_distributor
cargo check -p app_operation_system
```
Expected: both PASS
- [ ] **Step 8: Commit**
```bash
git add crates/plc_platform_core/src/handler/doc.rs crates/plc_platform_core/src/handler.rs crates/app_feeder_distributor/src/handler/doc.rs crates/app_operation_system/src/handler.rs crates/app_operation_system/src/handler/doc.rs crates/app_operation_system/src/lib.rs crates/app_operation_system/Cargo.toml docs/api-feeder.md docs/api-ops.md
git commit -m "refactor(core): move doc handler to core and split API.md per app"
```
## Task 6: Register Core Handlers In Ops App Router
**Files:**
- Modify: `crates/app_operation_system/src/router.rs`
- [ ] **Step 1: Add log and doc routes to ops router**
Update `crates/app_operation_system/src/router.rs`:
```rust
use axum::{extract::State, routing::get, Router};
use tower_http::services::ServeDir;
use crate::app::AppState;
async fn no_cache(
req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
let mut response = next.run(req).await;
response.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
response
}
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/api/health", get(health_check))
.route("/api/logs", get(plc_platform_core::handler::log::get_logs))
.route("/api/logs/stream", get(plc_platform_core::handler::log::stream_logs))
.route("/api/docs/api-md", get(crate::handler::doc::get_api_md))
.route("/api/docs/readme-md", get(crate::handler::doc::get_readme_md))
.nest(
"/ui",
Router::new()
.fallback_service(
ServeDir::new("web/ops")
.append_index_html_on_directories(true)
.fallback(ServeDir::new("web/core")),
)
.layer(axum::middleware::from_fn(no_cache)),
)
.with_state(state)
}
async fn health_check(State(state): State) -> String {
format!("{}:ok", state.app_name)
}
```
- [ ] **Step 2: Verify ops compiles and tests pass**
Run:
```bash
cargo check -p app_operation_system
cargo test -p app_operation_system
```
Expected: both PASS
- [ ] **Step 3: Commit**
```bash
git add crates/app_operation_system/src/router.rs
git commit -m "feat(ops): register log and doc routes from shared core"
```
## Task 7: Update Feeder Router API Doc Path Reference
**Files:**
- Modify: `crates/app_feeder_distributor/src/router.rs` (no change if doc handler wrapper handles path)
- [ ] **Step 1: Verify feeder doc route still works with renamed file**
The feeder doc handler now reads `docs/api-feeder.md` instead of `API.md`. The route `/api/docs/api-md` stays the same — only the file path changed inside the handler.
Run:
```bash
cargo test -p app_feeder_distributor
```
Expected: PASS
- [ ] **Step 2: Update README reference to API.md**
In `README.md`, replace the doc index entry:
Before:
```markdown
- API 接口说明: `API.md`
```
After:
```markdown
- 投煤器布料机 API: `docs/api-feeder.md`
- 运转系统 API: `docs/api-ops.md`
```
- [ ] **Step 3: Commit**
```bash
git add README.md
git commit -m "docs: update API doc references for per-app split"
```
## Task 8: Final Verification
- [ ] **Step 1: Run all workspace tests**
Run:
```bash
cargo test --workspace
```
Expected: all PASS
- [ ] **Step 2: Run release builds**
Run:
```bash
cargo build -p app_feeder_distributor --release
cargo build -p app_operation_system --release
```
Expected: both produce binaries
- [ ] **Step 3: Verify web file layout**
Run:
```bash
find web -type f | sort
```
Expected: core pages have no unit references, feeder overrides contain unit references, both apps have their own topbar and index.
- [ ] **Step 4: Verify core HTML has no unit references**
Run:
```bash
grep -ri "unit" web/core/ --include="*.html"
```
Expected: no matches
## Self-Review
### Coverage
- Three-panel UI (ops / app-config / platform-config): Tasks 1–3
- Core HTML cleaned of unit references: Task 1
- Feeder overrides via fallback chain: Task 2
- Log handler to core: Task 4
- Doc handler to core + API.md split: Tasks 5, 7
- Ops app gets log/doc routes: Task 6
- Build verification: Task 8
### What this plan does NOT cover (deferred)
- **PlatformContext completion**: Filling in pool/connection_manager/event_manager/ws_manager in the core context struct. This is a prerequisite for moving the remaining handlers (source, point, equipment, tag, page) to core.
- **Remaining handler migration**: source.rs (626 lines), point.rs (693 lines), equipment.rs (335 lines), tag.rs (126 lines), page.rs (169 lines) all depend on `AppState` and require PlatformContext to move to core.
- **Unit-panel JS wiring**: The standalone unit-panel in app-config view needs JS to render unit list and handle CRUD. Currently the unit rendering logic in `units.js` targets `#unitList` (in source-panel). Wiring `#unitConfigList` to the same data is a follow-up JS task.
### Key design decisions
- **ServeDir fallback override pattern**: Feeder overrides core pages by placing same-named files in `web/feeder/html/`. The fallback chain tries app dir first. This means core files are the "clean" base, feeder adds business-specific UI on top.
- **Separate unit-panel IDs**: Uses `refreshUnitBtn2`, `newUnitBtn2`, `unitConfigList` to avoid conflicts with the unit list embedded in the feeder source-panel override. Both are wired to the same `loadUnits()` / `openCreateUnitModal()` functions.
- **Log handler is fully stateless**: Reads files from `./logs` directory. No AppState dependency. Trivially movable to core.
- **Doc handler split**: Core provides `serve_markdown(path)` utility. Each app wraps it to point to its own API doc file.