Skip to content

feat: add real-time web dashboard (React + Express + SSE)#405

Closed
Hemang-05 wants to merge 2 commits intosantifer:mainfrom
Hemang-05:feature/web-dashboard
Closed

feat: add real-time web dashboard (React + Express + SSE)#405
Hemang-05 wants to merge 2 commits intosantifer:mainfrom
Hemang-05:feature/web-dashboard

Conversation

@Hemang-05
Copy link
Copy Markdown

@Hemang-05 Hemang-05 commented Apr 21, 2026

What does this PR do?

Adds a real-time web dashboard frontend (React + Vite) and backend (Express) to visualize the job search pipeline, tracked applications, and profile stats. The dashboard reads dynamically from the user's existing local data files (profile.yml, applications.md, pipeline.md) with graceful fallbacks and uses Server-Sent Events (SSE) to auto-refresh the UI whenever the data files change.

Related issue

Type of change

  • Bug fix
  • [*] New feature
  • Documentation / translation
  • Refactor (no behavior change)

Checklist

  • [* ] I have read CONTRIBUTING.md
  • [ *] I linked a related issue above (required for features and architecture changes)
  • [ *] My PR does not include personal data (CV, email, real names)
  • [ *] I ran node test-all.mjs and all tests pass
  • [ *] My changes respect the Data Contract (no modifications to user-layer files)
  • [ *] My changes align with the project roadmap

Questions? Join the Discord for faster feedback.

Summary by CodeRabbit

  • New Features

    • Introduced a Career‑Ops Web Dashboard with tabs for profile, pipeline, applications, and stats.
    • Real‑time data sync via Server‑Sent Events to auto-refresh when local data changes.
    • Single-command startup to run backend and frontend together.
  • Documentation

    • Added a README documenting app purpose, architecture, and quick start.
  • Chores

    • Added package manifests, dev scripts, lint/vite configs, HTML entry, and .gitignore updates.
    • Adjusted CI workflow input names for an action.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 04a00a43-4cba-4ba2-9fe0-5a417941eb83

📥 Commits

Reviewing files that changed from the base of the PR and between 4c2bfb0 and fd66bcf.

📒 Files selected for processing (1)
  • .github/workflows/welcome.yml

📝 Walkthrough

Walkthrough

Adds a new Career-Ops web dashboard: a React + Vite frontend, an Express backend serving file-backed APIs and SSE updates, local data parsing helpers, lint/build configs, README, and package scripts to run frontend and backend concurrently.

Changes

Cohort / File(s) Summary
Frontend app
dashboard-app/src/App.jsx, dashboard-app/src/main.jsx, dashboard-app/src/index.css, dashboard-app/index.html
New React app entry, main App component with tabbed UI, concurrent API fetching, SSE listener for data-changed events, toast logic, and global CSS theme/layout.
Frontend config & metadata
dashboard-app/package.json, dashboard-app/vite.config.js, dashboard-app/eslint.config.js, dashboard-app/.gitignore, dashboard-app/README.md
Added package manifest, Vite + React plugin config, ESLint flat config, README with usage/architecture, and gitignore for node/artifacts. Review deps, scripts, and lint rules.
Backend server & data parsing
web-server.mjs
New Express server exposing /api/profile, /api/applications, /api/pipeline, and SSE /api/events; parses YAML/Markdown from config/ and data/, watches data/ with debounced updates and streams data-changed events. Pay attention to parsing helpers, error handling, and SSE client management.
Repository-level changes
package.json, .gitignore, .github/workflows/welcome.yml
Added scripts to run backend/frontend concurrently, added express/cors/concurrently deps, updated root .gitignore to exclude dashboard artifacts, and adjusted workflow input names.

Sequence Diagram

sequenceDiagram
    participant Client as React Dashboard
    participant Server as Express Server
    participant FS as File System
    participant SSE as SSE Manager

    Client->>Server: GET /api/profile, /api/applications, /api/pipeline (concurrent)
    Server->>FS: Read config/profile.yml
    Server->>FS: Read data/applications.md
    Server->>FS: Read data/pipeline.md
    FS-->>Server: Return file contents
    Server-->>Client: JSON responses

    Client->>Server: GET /api/events (SSE)
    Server->>SSE: Register SSE connection

    Note over FS,Server: File changes in data/ (debounced)
    FS->>Server: Change event
    Server->>SSE: Emit "data-changed"
    SSE-->>Client: data-changed event

    Client->>Client: Show toast
    Client->>Server: Re-fetch APIs
    Server->>FS: Re-read updated files
    FS-->>Server: Updated contents
    Server-->>Client: Updated JSON
    Client->>Client: Update UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

📄 docs, 🔴 core-architecture

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add real-time web dashboard (React + Express + SSE)' accurately and concisely summarizes the main feature addition—a real-time dashboard with specified technology stack—matching the changeset which adds React frontend, Express backend, and SSE integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.gitignore:
- Line 33: The repository-wide package-lock.json ignore rule is preventing the
dashboard app's lockfile from being committed; add a negation entry to
.gitignore to allow the dashboard lockfile (i.e., add a line to whitelist the
dashboard app's package-lock) and ensure this negation appears after the general
package-lock.json ignore rule so the specific dashboard lockfile is tracked for
reproducible installs.

In `@dashboard-app/README.md`:
- Around line 15-22: The fenced code block in README.md uses triple backticks
without a language hint, triggering markdownlint MD040; update the opening fence
from ``` to ```text (leave the closing ``` as-is) so the block is treated as
plain text. Locate the README.md block that lists "dashboard-app/ React + Vite
frontend..." and change the opening fence to include the `text` language tag to
satisfy the linter.

In `@dashboard-app/src/App.jsx`:
- Around line 31-53: The fetchData function is recreated each render and used
inside two useEffect hooks (the initial fetch and the SSE handler), causing
exhaustive-deps/stale-closure issues; refactor fetchData into a stable callback
by wrapping it with useCallback (e.g., const fetchData = useCallback(..., [/*
its real deps */])) and then include fetchData in the dependency arrays of both
useEffect hooks (and keep showToast as a dependency for the SSE effect); ensure
the dependency list passed to useCallback contains any state/props fetchData
uses so the behavior remains correct.
- Around line 199-211: Add a local state (e.g., pipelineQuery) and an onChange
handler (e.g., handlePipelineQueryChange) for the search input, set the input's
value to pipelineQuery, and before rendering use a filtered list derived from
data.pipeline.pending that performs a case-insensitive substring match against
the job's company and role fields; then render that filtered array instead of
data.pipeline.pending so typing in the "Search jobs..." box actually filters
results.
- Around line 68-74: The fetch error handler currently only logs the error and
the finally block always calls setLastUpdated(new Date()), which makes the UI
show a refreshed time even on failure; change the flow so setLastUpdated is only
called on success (inside the try where data is set), add and set an error state
(e.g., setError) in the catch block and surface it to the user (toast or inline
banner), and keep setLoading(false) in finally so loading state is cleared
regardless of outcome; update references to setLastUpdated and setLoading
accordingly and ensure any existing UI reads the new error state to display the
failure.
- Around line 300-311: The Skills Inventory UI assumes every entry in
data.profile?.skills is an array and drops category metadata, causing crashes
and duplicate keys; update the rendering in the Skills block (the
Object.entries(...).map callback) to first normalize each skills value: if
Array.isArray(skills) use it, if it's a string wrap it in [skills], if it's an
object use Object.values(skills) (or fallback to an empty array), then map that
normalized array to <span>s; include the category label (category) in the output
(e.g., as a small header or aria-label) and use a stable composite key such as
`${category}-${skill}` instead of key={skill} to avoid collisions.
- Around line 259-276: The status badge class generation is fragile: when
app.status is undefined or contains spaces/punctuation the template literal
`status-${app.status}` (used in the JSX mapping over data.applications) emits
invalid/incorrect class names; update the rendering inside the
data.applications.map (the <span className=...> that uses `status-${app.status}`
and the surrounding status-badge markup) to compute a normalized suffix: coerce
undefined/null to a fallback like "unknown", convert to lowercase, replace any
non-alphanumeric characters (including spaces) with a single hyphen (or strip
them), and then interpolate that sanitized string into the class (e.g.,
`status-${sanitized}`). Ensure the normalization logic is applied wherever
`app.status` is used for class names so badges get consistent classes.

In `@dashboard-app/src/index.css`:
- Around line 110-113: Add a .status-Offer CSS rule so badges rendered by
App.jsx (<span className={`status-badge status-${app.status}`}> and the funnel
that includes "Offer") are styled like the other statuses; implement a rule
named .status-Offer alongside
.status-Evaluated/.status-Applied/.status-Interview/.status-Rejected using a
suitable background RGBA and color that matches the UI palette (e.g.,
amber/yellow tone) so "Offer" badges are not unstyled.

In `@package.json`:
- Around line 19-21: The backend currently calls app.use(cors()) and starts the
HTTP server without binding to loopback; update web-server.mjs to replace the
permissive app.use(cors()) with a CORS middleware configured to only allow the
intended origin(s) (or disable CORS for all if same-origin), and change the
server start call (e.g., server.listen or app.listen in web-server.mjs) to bind
explicitly to the loopback address (127.0.0.1) rather than the default 0.0.0.0
so the API is not exposed on all network interfaces.
- Around line 19-21: The npm scripts ("frontend", "backend", "dashboard")
require workspace configuration so running "npm run dashboard" from the root
will also install dashboard-app dependencies; add an npm workspaces entry in
package.json that lists "dashboard-app" (and any other packages) so a root `npm
install` installs its dependencies, then verify the "frontend" script still
references `npm run dev --prefix dashboard-app` and that "dashboard" uses
concurrently to run "backend" and "frontend". Include the workspace name
"dashboard-app" in the "workspaces" array in package.json and run a fresh
install to confirm the dashboard script works for new users.

In `@web-server.mjs`:
- Around line 69-84: The parsePendingList function currently scans the whole
file for '- [ ]' lines and can pick up unrelated checkboxes; update
parsePendingList to first extract only the "## Pendientes" section (everything
from the '## Pendientes' header to the next '## ' header) and then parse lines
within that subsection, and additionally validate the parsed parts[0] using a
URL check (e.g. test against /^https?:\/\//) before pushing an entry so only
proper URL-formatted entries are added.
- Around line 53-67: The parseMarkdownTable function is fragile: first validate
that the second line is a Markdown separator (match lines[1] against
/^\|[\s\-:|]+\|$/) and if it does not match, return [] so you only treat a true
header+separator pair as a table; when extracting cells for headers and rows,
split on '|' but keep empty leading/trailing segments consistently (do not drop
the leading empty segment) and normalize the resulting arrays to the header
length by padding missing cells with empty strings so undefineds aren't
produced; also sanitize cell values by trimming whitespace and stripping common
Markdown wrappers (e.g., surrounding **, __, backticks, and extraneous inline
code markers) before assigning into the row objects; update references in
parseMarkdownTable to use the validated headers and padded values to ensure
alignment with App.jsx consumers.
- Around line 38-49: The watcher currently calls fs.watch on dataDir and crashes
if the data/ directory is missing; before creating the watcher (where dataDir,
debounceTimer, fs.watch, sseClients and broadcastUpdate are used) ensure the
directory exists or create it (e.g., check with fs.existsSync and call
fs.mkdirSync(dataDir, { recursive: true }) or equivalent), then only call
fs.watch inside a safe block (or wrap it in try/catch) so ENOENT won't crash the
process and the debounce/broadcast logic remains unchanged.
- Around line 11-15: The CORS and listen configuration is too permissive:
replace the open app.use(cors()) with a CORS policy that only allows the
dashboard origin (e.g., 'http://localhost:5173') and any needed options (like
credentials if your frontend uses cookies), and change app.listen(PORT) to bind
to localhost (127.0.0.1) instead of all interfaces so the server only accepts
local connections; update the calls referencing app.use(cors()) and
app.listen(PORT) accordingly to use the restricted origin and host.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c36b8a69-6fab-4b52-aa90-c9f66580a02b

📥 Commits

Reviewing files that changed from the base of the PR and between 411afb3 and 4c2bfb0.

⛔ Files ignored due to path filters (2)
  • dashboard-app/public/favicon.svg is excluded by !**/*.svg
  • dashboard-app/public/icons.svg is excluded by !**/*.svg
📒 Files selected for processing (12)
  • .gitignore
  • dashboard-app/.gitignore
  • dashboard-app/README.md
  • dashboard-app/eslint.config.js
  • dashboard-app/index.html
  • dashboard-app/package.json
  • dashboard-app/src/App.jsx
  • dashboard-app/src/index.css
  • dashboard-app/src/main.jsx
  • dashboard-app/vite.config.js
  • package.json
  • web-server.mjs

Comment thread .gitignore
.resolved-prompt-*
node_modules/
dashboard-app/node_modules/
dashboard-app/package-lock.json
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Show which .gitignore rule is ignoring the dashboard npm lockfile.
# Expected before the fix: a matching ignore rule is printed.
# Expected after the fix: no ignore rule is printed.
git check-ignore -v dashboard-app/package-lock.json || true

Repository: santifer/career-ops

Length of output: 126


Allow-list the dashboard lockfile for reproducible installs.

The file dashboard-app/package-lock.json is currently ignored by the package-lock.json rule on line 45. Since the dashboard app uses npm with semver ranges in package.json, without tracking the lockfile, installs can drift across machines and CI. Add a negation rule to allow-list it:

Proposed fix
 package-lock.json
+!dashboard-app/package-lock.json
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.gitignore at line 33, The repository-wide package-lock.json ignore rule is
preventing the dashboard app's lockfile from being committed; add a negation
entry to .gitignore to allow the dashboard lockfile (i.e., add a line to
whitelist the dashboard app's package-lock) and ensure this negation appears
after the general package-lock.json ignore rule so the specific dashboard
lockfile is tracked for reproducible installs.

Comment thread dashboard-app/README.md
Comment on lines +15 to +22
```
dashboard-app/ React + Vite frontend (this directory)
web-server.mjs Express backend (project root)
config/profile.yml → /api/profile
data/applications.md → /api/applications
data/pipeline.md → /api/pipeline
/api/events (SSE stream)
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Specify a language on the fenced code block.

markdownlint (MD040) flags this block. Use a non-rendered hint like text so renderers don't apply heuristics.

✏️ Proposed fix
-```
+```text
 dashboard-app/          React + Vite frontend (this directory)
 web-server.mjs          Express backend (project root)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
dashboard-app/ React + Vite frontend (this directory)
web-server.mjs Express backend (project root)
config/profile.yml → /api/profile
data/applications.md → /api/applications
data/pipeline.md → /api/pipeline
/api/events (SSE stream)
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 15-15: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard-app/README.md` around lines 15 - 22, The fenced code block in
README.md uses triple backticks without a language hint, triggering markdownlint
MD040; update the opening fence from ``` to ```text (leave the closing ```
as-is) so the block is treated as plain text. Locate the README.md block that
lists "dashboard-app/ React + Vite frontend..." and change the opening fence to
include the `text` language tag to satisfy the linter.

Comment thread dashboard-app/src/App.jsx
Comment on lines +31 to +53
// Initial fetch
useEffect(() => {
fetchData();
}, []);

// SSE: Listen for file changes from the backend
useEffect(() => {
const es = new EventSource('http://localhost:3001/api/events');
es.onmessage = (event) => {
if (event.data === 'connected') return;
try {
const payload = JSON.parse(event.data);
if (payload.type === 'data-changed') {
fetchData();
showToast(`📡 Data updated (${payload.source})`);
}
} catch { /* ignore parse errors */ }
};
es.onerror = () => {
// SSE will auto-reconnect
};
return () => es.close();
}, [showToast]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

fetchData isn't in the effect dependency lists and isn't stable.

fetchData is recreated on every render, but it's referenced from two useEffect hooks that don't declare it as a dependency (lines 32‑34 and 37‑53). React's exhaustive-deps rule will flag this, and it's a genuine stale-closure risk if fetchData ever starts depending on props/state. Wrap it in useCallback:

♻️ Proposed refactor
-  const fetchData = async () => {
+  const fetchData = useCallback(async () => {
     setLoading(true);
     ...
-  };
+  }, []);
-  useEffect(() => {
-    fetchData();
-  }, []);
+  useEffect(() => {
+    fetchData();
+  }, [fetchData]);
-  }, [showToast]);
+  }, [showToast, fetchData]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard-app/src/App.jsx` around lines 31 - 53, The fetchData function is
recreated each render and used inside two useEffect hooks (the initial fetch and
the SSE handler), causing exhaustive-deps/stale-closure issues; refactor
fetchData into a stable callback by wrapping it with useCallback (e.g., const
fetchData = useCallback(..., [/* its real deps */])) and then include fetchData
in the dependency arrays of both useEffect hooks (and keep showToast as a
dependency for the SSE effect); ensure the dependency list passed to useCallback
contains any state/props fetchData uses so the behavior remains correct.

Comment thread dashboard-app/src/App.jsx
Comment on lines +68 to +74
} catch (err) {
console.error('Failed to fetch data', err);
} finally {
setLoading(false);
setLastUpdated(new Date());
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error path still advances lastUpdated and silently keeps stale data.

On fetch failure you only console.error, then set lastUpdated to new Date() — the UI will claim data was "Last refreshed" at the error time while showing whatever was in state before. Surface the error to the user (toast or inline banner) and avoid bumping lastUpdated when the request failed.

♻️ Proposed fix
     } catch (err) {
       console.error('Failed to fetch data', err);
+      showToast('⚠️ Failed to load data from backend');
+      setLoading(false);
+      return;
     } finally {
       setLoading(false);
       setLastUpdated(new Date());
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard-app/src/App.jsx` around lines 68 - 74, The fetch error handler
currently only logs the error and the finally block always calls
setLastUpdated(new Date()), which makes the UI show a refreshed time even on
failure; change the flow so setLastUpdated is only called on success (inside the
try where data is set), add and set an error state (e.g., setError) in the catch
block and surface it to the user (toast or inline banner), and keep
setLoading(false) in finally so loading state is cleared regardless of outcome;
update references to setLastUpdated and setLoading accordingly and ensure any
existing UI reads the new error state to display the failure.

Comment thread dashboard-app/src/App.jsx
Comment on lines +199 to +211
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
<h3>Found in Scans ({data.pipeline.pending.length})</h3>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<div style={{ position: 'relative' }}>
<Search size={16} style={{ position: 'absolute', left: '10px', top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)' }} />
<input
type="text"
placeholder="Search jobs..."
style={{ padding: '0.5rem 1rem 0.5rem 2rem', background: 'rgba(255,255,255,0.05)', border: '1px solid var(--border-color)', borderRadius: '0.5rem', color: 'white' }}
/>
</div>
</div>
</div>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Search input is non-functional.

The field has a placeholder "Search jobs..." but no value/onChange handler and the table renders the full list unconditionally. Users will type and see nothing filter. Either wire it to a local filter state or remove the input until the feature lands.

✏️ Proposed fix — add a filter
+                const [pipelineQuery, setPipelineQuery] = useState(''); // hoist into App
...
                     <input 
                       type="text" 
                       placeholder="Search jobs..." 
+                      value={pipelineQuery}
+                      onChange={(e) => setPipelineQuery(e.target.value)}
                       style={{ ... }}
                     />

And filter data.pipeline.pending by pipelineQuery (case-insensitive match on company/role) before rendering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@dashboard-app/src/App.jsx` around lines 199 - 211, Add a local state (e.g.,
pipelineQuery) and an onChange handler (e.g., handlePipelineQueryChange) for the
search input, set the input's value to pipelineQuery, and before rendering use a
filtered list derived from data.pipeline.pending that performs a
case-insensitive substring match against the job's company and role fields; then
render that filtered array instead of data.pipeline.pending so typing in the
"Search jobs..." box actually filters results.

Comment thread package.json
Comment on lines +19 to +21
"backend": "node web-server.mjs",
"frontend": "npm run dev --prefix dashboard-app",
"dashboard": "concurrently \"npm run backend\" \"npm run frontend\""
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect backend CORS and listen-host configuration.
# Expected after the fix: no bare app.use(cors()) match, and app.listen includes a loopback host.
rg -nP -C3 'app\.use\s*\(\s*cors\s*\(\s*\)\s*\)' --iglob 'web-server.mjs' || true
rg -nP -C3 'app\.listen\s*\(' --iglob 'web-server.mjs' || true

Repository: santifer/career-ops

Length of output: 639


Restrict CORS and bind backend to loopback before exposing local career data.

The web-server.mjs backend serves local profile and application data. Line 14 uses app.use(cors()), allowing any website to read these APIs, and line 123 lacks loopback binding, making the server accessible from any network interface. Both must be fixed.

🛡️ Example hardening for `web-server.mjs`
-app.use(cors());
+const allowedOrigins = new Set([
+  'http://localhost:5173',
+  'http://127.0.0.1:5173',
+]);
+
+app.use(cors({
+  origin(origin, callback) {
+    if (!origin || allowedOrigins.has(origin)) {
+      return callback(null, true);
+    }
+    return callback(new Error('Not allowed by CORS'));
+  },
+}));

Also bind to loopback:

-app.listen(PORT, () => {
+app.listen(PORT, '127.0.0.1', () => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 19 - 21, The backend currently calls
app.use(cors()) and starts the HTTP server without binding to loopback; update
web-server.mjs to replace the permissive app.use(cors()) with a CORS middleware
configured to only allow the intended origin(s) (or disable CORS for all if
same-origin), and change the server start call (e.g., server.listen or
app.listen in web-server.mjs) to bind explicitly to the loopback address
(127.0.0.1) rather than the default 0.0.0.0 so the API is not exposed on all
network interfaces.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether root package.json is configured to install dashboard-app dependencies.
# Expected after the fix: package.json includes a workspaces entry containing dashboard-app.
jq '.workspaces // null' package.json
jq '.devDependencies.vite // .dependencies.vite // null' dashboard-app/package.json

Repository: santifer/career-ops

Length of output: 76


🏁 Script executed:

#!/bin/bash
# Check web-server.mjs for CORS configuration
cat -n web-server.mjs | head -50

Repository: santifer/career-ops

Length of output: 1838


🏁 Script executed:

#!/bin/bash
# Check for postinstall or other scripts in root package.json
jq '.scripts | keys' package.json

Repository: santifer/career-ops

Length of output: 278


Configure npm workspaces to install dashboard-app dependencies from root.

npm run dashboard depends on frontend dependencies in dashboard-app (vite, React, etc.), but a fresh root npm install will not install them without workspace configuration. New users will encounter missing dependencies when running the dashboard script.

🛠️ Recommended workspace fix
   "description": "AI-powered job search pipeline built on Claude Code",
+  "private": true,
+  "workspaces": [
+    "dashboard-app"
+  ],
   "scripts": {
@@
-    "frontend": "npm run dev --prefix dashboard-app",
+    "frontend": "npm run dev --workspace dashboard-app",
     "dashboard": "concurrently \"npm run backend\" \"npm run frontend\""
   },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@package.json` around lines 19 - 21, The npm scripts ("frontend", "backend",
"dashboard") require workspace configuration so running "npm run dashboard" from
the root will also install dashboard-app dependencies; add an npm workspaces
entry in package.json that lists "dashboard-app" (and any other packages) so a
root `npm install` installs its dependencies, then verify the "frontend" script
still references `npm run dev --prefix dashboard-app` and that "dashboard" uses
concurrently to run "backend" and "frontend". Include the workspace name
"dashboard-app" in the "workspaces" array in package.json and run a fresh
install to confirm the dashboard script works for new users.

Comment thread web-server.mjs
Comment on lines +11 to +15
const app = express();
const PORT = 3001;

app.use(cors());
app.use(express.json());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd -t f "web-server.mjs" --exec cat -n {} \;

Repository: santifer/career-ops

Length of output: 4880


🏁 Script executed:

fd -t f "\.(ts|js|mjs|json|vue)$" dashboard templates 2>/dev/null | head -20

Repository: santifer/career-ops

Length of output: 45


🏁 Script executed:

rg "5173" --type-list | head -5 && rg "5173" -t json -t mjs -t js -t ts -t md -A 2 -B 2

Repository: santifer/career-ops

Length of output: 480


🏁 Script executed:

rg "5173" -A 2 -B 2

Repository: santifer/career-ops

Length of output: 343


🏁 Script executed:

fd -t f "vite.config\|package.json\|tsconfig\|webpack" --exec grep -l "5173" {} \;

Repository: santifer/career-ops

Length of output: 45


Restrict CORS origin and bind to localhost only to prevent local data exfiltration.

app.use(cors()) defaults to Access-Control-Allow-Origin: *, and app.listen(PORT) (line 123) binds to all interfaces (0.0.0.0). This exposes the API to:

  • Any web page the user visits while the dev server runs can fetch('http://localhost:3001/api/profile') and exfiltrate their config/profile.yml contents
  • Any host on the LAN can reach these endpoints directly

Since this API is intended purely for the local dashboard on port 5173, restrict both the origin and bind address:

🔒 Proposed fix
-app.use(cors());
+app.use(cors({ origin: 'http://localhost:5173' }));
 app.use(express.json());
-app.listen(PORT, () => {
+app.listen(PORT, '127.0.0.1', () => {
   console.log(`Backend server running at http://localhost:${PORT}`);
   console.log(`Watching ${dataDir} for changes...`);
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-server.mjs` around lines 11 - 15, The CORS and listen configuration is
too permissive: replace the open app.use(cors()) with a CORS policy that only
allows the dashboard origin (e.g., 'http://localhost:5173') and any needed
options (like credentials if your frontend uses cookies), and change
app.listen(PORT) to bind to localhost (127.0.0.1) instead of all interfaces so
the server only accepts local connections; update the calls referencing
app.use(cors()) and app.listen(PORT) accordingly to use the restricted origin
and host.

Comment thread web-server.mjs
Comment on lines +38 to +49
// Watch the data directory for changes
const dataDir = path.join(__dirname, 'data');
let debounceTimer = null;
fs.watch(dataDir, { recursive: false }, (eventType, filename) => {
if (!filename) return;
// Debounce: scan.mjs writes multiple files in quick succession
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`[watch] ${filename} changed → notifying ${sseClients.size} client(s)`);
broadcastUpdate(filename);
}, 500);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Server crashes on startup if data/ directory is missing.

fs.watch throws ENOENT synchronously when the target directory doesn't exist. On a fresh clone (no scan has been run yet), running npm run backend will crash immediately — contradicting the README's promise that "missing local data/config files... shows empty states."

🛡️ Proposed fix — ensure dir exists and guard the watcher
-// Watch the data directory for changes
-const dataDir = path.join(__dirname, 'data');
-let debounceTimer = null;
-fs.watch(dataDir, { recursive: false }, (eventType, filename) => {
-  if (!filename) return;
-  // Debounce: scan.mjs writes multiple files in quick succession
-  clearTimeout(debounceTimer);
-  debounceTimer = setTimeout(() => {
-    console.log(`[watch] ${filename} changed → notifying ${sseClients.size} client(s)`);
-    broadcastUpdate(filename);
-  }, 500);
-});
+// Watch the data directory for changes
+const dataDir = path.join(__dirname, 'data');
+let debounceTimer = null;
+try {
+  fs.mkdirSync(dataDir, { recursive: true });
+  fs.watch(dataDir, { recursive: false }, (eventType, filename) => {
+    if (!filename) return;
+    // Debounce: scan.mjs writes multiple files in quick succession
+    clearTimeout(debounceTimer);
+    debounceTimer = setTimeout(() => {
+      console.log(`[watch] ${filename} changed → notifying ${sseClients.size} client(s)`);
+      broadcastUpdate(filename);
+    }, 500);
+  });
+} catch (err) {
+  console.warn(`[watch] Could not watch ${dataDir}: ${err.message}. Live updates disabled.`);
+}

As per coding guidelines: "Ensure scripts handle missing data/ directories gracefully."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Watch the data directory for changes
const dataDir = path.join(__dirname, 'data');
let debounceTimer = null;
fs.watch(dataDir, { recursive: false }, (eventType, filename) => {
if (!filename) return;
// Debounce: scan.mjs writes multiple files in quick succession
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`[watch] ${filename} changed → notifying ${sseClients.size} client(s)`);
broadcastUpdate(filename);
}, 500);
});
// Watch the data directory for changes
const dataDir = path.join(__dirname, 'data');
let debounceTimer = null;
try {
fs.mkdirSync(dataDir, { recursive: true });
fs.watch(dataDir, { recursive: false }, (eventType, filename) => {
if (!filename) return;
// Debounce: scan.mjs writes multiple files in quick succession
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`[watch] ${filename} changed → notifying ${sseClients.size} client(s)`);
broadcastUpdate(filename);
}, 500);
});
} catch (err) {
console.warn(`[watch] Could not watch ${dataDir}: ${err.message}. Live updates disabled.`);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-server.mjs` around lines 38 - 49, The watcher currently calls fs.watch on
dataDir and crashes if the data/ directory is missing; before creating the
watcher (where dataDir, debounceTimer, fs.watch, sseClients and broadcastUpdate
are used) ensure the directory exists or create it (e.g., check with
fs.existsSync and call fs.mkdirSync(dataDir, { recursive: true }) or
equivalent), then only call fs.watch inside a safe block (or wrap it in
try/catch) so ENOENT won't crash the process and the debounce/broadcast logic
remains unchanged.

Comment thread web-server.mjs
Comment on lines +53 to +67
function parseMarkdownTable(content) {
const lines = content.split('\n').filter(l => l.trim().startsWith('|'));
if (lines.length < 3) return [];

const headers = lines[0].split('|').map(h => h.trim()).filter(Boolean);
const data = lines.slice(2).map(line => {
const values = line.split('|').map(v => v.trim()).filter((_, i) => i > 0 && i <= headers.length);
const row = {};
headers.forEach((h, i) => {
row[h.toLowerCase()] = values[i];
});
return row;
});
return data;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

parseMarkdownTable has fragile row/header handling.

A few rough edges worth tightening:

  • line.split('|').map(...).filter((_, i) => i > 0 && i <= headers.length) drops the leading empty segment from the |-delimited row, but if a row has fewer cells than headers, the resulting object quietly has undefined values and callers must defensively render. Worse, the separator row detection assumes lines[1] is always | --- | --- | — if someone writes a plain text line beginning with | between header and separator, parsing silently misaligns.
  • Headers are lowercased but values aren't trimmed of markdown (e.g. **bold**, backticks) — consumers in App.jsx render them as-is.

Consider validating that lines[1] matches a separator pattern (/^\|[\s\-:|]+\|$/) before treating lines[0] as a header, and skipping otherwise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-server.mjs` around lines 53 - 67, The parseMarkdownTable function is
fragile: first validate that the second line is a Markdown separator (match
lines[1] against /^\|[\s\-:|]+\|$/) and if it does not match, return [] so you
only treat a true header+separator pair as a table; when extracting cells for
headers and rows, split on '|' but keep empty leading/trailing segments
consistently (do not drop the leading empty segment) and normalize the resulting
arrays to the header length by padding missing cells with empty strings so
undefineds aren't produced; also sanitize cell values by trimming whitespace and
stripping common Markdown wrappers (e.g., surrounding **, __, backticks, and
extraneous inline code markers) before assigning into the row objects; update
references in parseMarkdownTable to use the validated headers and padded values
to ensure alignment with App.jsx consumers.

Comment thread web-server.mjs
Comment on lines +69 to +84
function parsePendingList(content) {
const pendingList = [];
// Match ALL checkbox items in the file, not just under Pendientes header
const items = content.split('\n').filter(l => l.trim().startsWith('- [ ]'));
items.forEach(item => {
const parts = item.replace('- [ ]', '').split('|').map(p => p.trim());
if (parts.length >= 2) {
pendingList.push({
url: parts[0],
company: parts[1],
role: parts[2] || 'Unknown'
});
}
});
return pendingList;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

parsePendingList may pick up unrelated checkbox items.

The comment acknowledges this is intentional, but since it scans the entire pipeline.md for any - [ ], unrelated checkboxes (notes, TODOs, instructions) will surface as "pending jobs" in the dashboard. If you expect structured entries only, scope the scan to a known section header (e.g. everything between ## Pendientes and the next ## ) or require the URL segment to look like a URL (/^https?:\/\//.test(parts[0])) before pushing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web-server.mjs` around lines 69 - 84, The parsePendingList function currently
scans the whole file for '- [ ]' lines and can pick up unrelated checkboxes;
update parsePendingList to first extract only the "## Pendientes" section
(everything from the '## Pendientes' header to the next '## ' header) and then
parse lines within that subsection, and additionally validate the parsed
parts[0] using a URL check (e.g. test against /^https?:\/\//) before pushing an
entry so only proper URL-formatted entries are added.

@santifer
Copy link
Copy Markdown
Owner

Welcome to career-ops, @Hemang-05! This is polished work — React + Vite + SSE for live data sync is exactly the stack choice I'd make if we were building a web dashboard.

I'm closing this because we made an architecture decision about why career-ops uses a single persistent agent instead of spawn-and-kill web servers: Discussion #274. A web dashboard that spawns new processes per user action goes against that direction.

A web UI is on our roadmap, but it needs to connect to a persistent agent rather than create new ones. Your frontend skills would be welcome when we get there — keep an eye on the Discussions.

@santifer santifer closed this Apr 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants