Skip to content

Commit 59c4d60

Browse files
committed
feat: implement visual dashboard and enhanced intelligence
- add scoring for copy-suffix duplicates (prioritizing originals) - implement archive vs. extracted folder detection - create REST API backend for dashboard - build modern React/Vite dashboard for visual review - add ui:build and ui CLI commands
1 parent d87e4ad commit 59c4d60

25 files changed

Lines changed: 3791 additions & 9 deletions

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@
3434
"check:quality": "node scripts/check-quality.js",
3535
"check:security": "node scripts/check-security.js",
3636
"test:smoke": "node scripts/run-smoke.js",
37-
"test": "node --test test/engagement.test.js test/executor.test.js test/local-audit.test.js test/local-drive.test.js test/protection.test.js test/review-queue.test.js"
37+
"test": "node --test test/engagement.test.js test/executor.test.js test/local-audit.test.js test/local-drive.test.js test/protection.test.js test/review-queue.test.js",
38+
"ui:build": "cd ui && npm run build"
3839
},
3940
"dependencies": {
40-
"better-sqlite3": "^12.9.0"
41+
"better-sqlite3": "^12.9.0",
42+
"express": "^5.2.1"
4143
}
4244
}

src/cli.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ensureLocalDriveScaffold, getLocalDriveStatus } from "./providers/local
1818
import { createMockProviderSnapshots } from "./providers/mock-snapshots.js";
1919
import { explainPricingStrategy } from "./advisory/pricing-catalog.js";
2020
import { Catalog } from "./core/catalog.js";
21+
import { startServer } from "./server.js";
2122

2223
const [, , command, ...args] = process.argv;
2324
const DEFAULT_DB_PATH = ".nyx/nyx.db";
@@ -47,7 +48,8 @@ const handlers = {
4748
"sync-file": runSyncFile,
4849
decide: runDecide,
4950
fingerprint: runFingerprint,
50-
classify: runClassify
51+
classify: runClassify,
52+
ui: runUi
5153
};
5254

5355
async function main() {
@@ -392,6 +394,12 @@ async function runClassify(args) {
392394
console.log(JSON.stringify(classifyFile(fileProfile), null, 2));
393395
}
394396

397+
async function runUi(args) {
398+
const port = parseInt(args[0] ?? "3000", 10);
399+
const dbPath = args[1] ?? DEFAULT_DB_PATH;
400+
await startServer({ port, dbPath });
401+
}
402+
395403
async function buildProfileForClassification(filePath) {
396404
const profile = await fingerprintFile(filePath);
397405
return {
@@ -416,6 +424,7 @@ function printUsage() {
416424
console.log("- plan");
417425
console.log("- doctor");
418426
console.log("- demo");
427+
console.log("- ui [port] [db-path]");
419428
console.log("- engagement-summary [engagement-path]");
420429
console.log("- audit-local [engagement-path]");
421430
console.log("- review-local [engagement-path]");

src/organization/irrelevance.js

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,62 @@
1+
import path from "node:path";
12
import { isEligibleDuplicateGroup } from "./eligibility.js";
23

3-
export function findIrrelevanceFindings({ duplicates = [], configuredRules = [] } = {}) {
4+
export function findIrrelevanceFindings({ duplicates = [], files = [], directories = [], configuredRules = [] } = {}) {
45
const normalizedRules = configuredRules.map((rule) => rule.toLowerCase());
56
const findings = [];
67

78
if (normalizedRules.includes("exact duplicate files by content hash")) {
89
findings.push(...duplicates.filter(isEligibleDuplicateGroup).map(buildDuplicateFinding));
910
}
1011

12+
// New rule: redundant archives
13+
findings.push(...findExtractedArchives({ files, directories }));
14+
1115
return findings.sort((left, right) => left.id.localeCompare(right.id));
1216
}
1317

18+
function findExtractedArchives({ files, directories }) {
19+
const findings = [];
20+
const ARCHIVE_EXTENSIONS = [".zip", ".7z", ".rar", ".tar", ".gz", ".tgz"];
21+
const directoryPaths = new Set(directories.map(d => d.absolutePath.toLowerCase()));
22+
23+
for (const file of files) {
24+
const baseName = file.baseName || path.basename(file.absolutePath || "");
25+
if (!baseName) continue;
26+
27+
const ext = path.extname(baseName).toLowerCase();
28+
if (!ARCHIVE_EXTENSIONS.includes(ext)) continue;
29+
30+
const baseNameWithoutExt = baseName.slice(0, -ext.length);
31+
const possibleDirPath = path.join(path.dirname(file.absolutePath), baseNameWithoutExt);
32+
33+
if (directoryPaths.has(possibleDirPath.toLowerCase())) {
34+
findings.push({
35+
id: `irrelevance:extracted_archive:${file.sha256}:${file.relativePath}`,
36+
type: "irrelevance_finding",
37+
action: "review_archive_cleanup",
38+
status: "pending_user_approval",
39+
approvalGate: "deleting irrelevant files",
40+
risk: "destructive",
41+
subjectPath: file.absolutePath,
42+
matchedRule: "archive exists alongside extracted folder",
43+
confidence: "medium",
44+
reviewOnly: true,
45+
evidence: {
46+
archivePath: file.absolutePath,
47+
extractedFolderPath: possibleDirPath,
48+
reasons: ["This archive appears to have been extracted into a folder in the same location."]
49+
}
50+
});
51+
}
52+
}
53+
54+
return findings;
55+
}
56+
1457
function buildDuplicateFinding(group) {
15-
const [proposedKeepFile, ...proposedDeleteFiles] = group.files;
58+
const proposedKeepFile = identifyProposedKeepFile(group.files);
59+
const proposedDeleteFiles = group.files.filter((file) => file.absolutePath !== proposedKeepFile.absolutePath);
1660

1761
return {
1862
id: `irrelevance:duplicate:${group.sha256}`,
@@ -37,3 +81,27 @@ function buildDuplicateFinding(group) {
3781
}
3882
};
3983
}
84+
85+
function identifyProposedKeepFile(files) {
86+
// Score files based on "originality" (lower score is better/more original)
87+
const scoredFiles = files.map((file) => {
88+
let score = 0;
89+
const name = file.baseName || path.basename(file.absolutePath || "");
90+
91+
// Penalize common download suffixes
92+
if (name) {
93+
const baseNameOnly = path.parse(name.toLowerCase()).name;
94+
if (/\(\d+\)$/.test(baseNameOnly)) score += 10;
95+
if (name.toLowerCase().includes("copy")) score += 5;
96+
if (name.toLowerCase().includes("duplicate")) score += 5;
97+
}
98+
99+
// Prefer shorter paths (usually more root-level/intentional)
100+
score += (file.relativePath || "").split("/").length;
101+
102+
return { file, score };
103+
});
104+
105+
scoredFiles.sort((a, b) => a.score - b.score);
106+
return scoredFiles[0].file;
107+
}

src/organization/local-audit.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ export async function buildLocalAudit({
6363
const organizationProposals = buildOrganizationProposals(files);
6464
const irrelevanceFindings = findIrrelevanceFindings({
6565
duplicates,
66+
files,
67+
directories: scanResult.directories,
6668
configuredRules: engagement.safeIrrelevanceRules
6769
});
6870

src/organization/scan-managed.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import path from "node:path";
44
export async function scanManagedDirectories({ managedDirectories, exclusions = [] }) {
55
const normalizedExclusions = exclusions.map((entry) => normalizeSegment(entry));
66
const files = [];
7+
const directories = [];
78
const roots = [];
89
const missingDirectories = [];
910

@@ -12,14 +13,17 @@ export async function scanManagedDirectories({ managedDirectories, exclusions =
1213

1314
try {
1415
const rootFiles = [];
16+
const rootDirs = [];
1517
await walkDirectory({
1618
rootPath,
1719
currentPath: rootPath,
1820
exclusions: normalizedExclusions,
19-
files: rootFiles
21+
files: rootFiles,
22+
directories: rootDirs
2023
});
2124

2225
files.push(...rootFiles);
26+
directories.push(...rootDirs);
2327
roots.push({
2428
rootPath,
2529
fileCount: rootFiles.length
@@ -36,12 +40,13 @@ export async function scanManagedDirectories({ managedDirectories, exclusions =
3640

3741
return {
3842
files,
43+
directories,
3944
roots,
4045
missingDirectories
4146
};
4247
}
4348

44-
async function walkDirectory({ rootPath, currentPath, exclusions, files }) {
49+
async function walkDirectory({ rootPath, currentPath, exclusions, files, directories }) {
4550
const entries = await readdir(currentPath, { withFileTypes: true });
4651

4752
for (const entry of entries) {
@@ -53,11 +58,17 @@ async function walkDirectory({ rootPath, currentPath, exclusions, files }) {
5358
}
5459

5560
if (entry.isDirectory()) {
61+
directories.push({
62+
rootPath,
63+
absolutePath,
64+
relativePath
65+
});
5666
await walkDirectory({
5767
rootPath,
5868
currentPath: absolutePath,
5969
exclusions,
60-
files
70+
files,
71+
directories
6172
});
6273
continue;
6374
}
@@ -92,4 +103,3 @@ function normalizeSegment(value) {
92103
function isMissingPathError(error) {
93104
return error?.code === "ENOENT" || error?.code === "ENOTDIR";
94105
}
95-

src/server.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import express from "express";
2+
import path from "node:path";
3+
import { Catalog } from "./core/catalog.js";
4+
import { applyApprovedReview } from "./organization/executor.js";
5+
6+
const DEFAULT_DB_PATH = ".nyx/nyx.db";
7+
8+
export async function startServer({ port = 3000, dbPath = DEFAULT_DB_PATH } = {}) {
9+
const app = express();
10+
const catalog = await Catalog.open(dbPath);
11+
12+
app.use(express.json());
13+
14+
// API Routes
15+
app.get("/api/overview", async (req, res) => {
16+
try {
17+
const files = catalog.getAllFiles();
18+
const items = catalog.getPendingReviewItems();
19+
20+
const stats = {
21+
totalFiles: files.length,
22+
totalSize: files.reduce((sum, f) => sum + f.sizeBytes, 0),
23+
pendingItems: items.length,
24+
duplicates: items.filter(i => i.action === "review_duplicate_deletion").length,
25+
proposals: items.filter(i => i.type === "organization_proposal").length,
26+
archives: items.filter(i => i.action === "review_archive_cleanup").length
27+
};
28+
29+
res.json(stats);
30+
} catch (error) {
31+
res.status(500).json({ error: error.message });
32+
}
33+
});
34+
35+
app.get("/api/items", async (req, res) => {
36+
try {
37+
const items = catalog.getPendingReviewItems();
38+
res.json(items);
39+
} catch (error) {
40+
res.status(500).json({ error: error.message });
41+
}
42+
});
43+
44+
app.post("/api/items/:id/approve", async (req, res) => {
45+
try {
46+
const { id } = req.params;
47+
if (id === "all") {
48+
catalog.approveAllReviewItems();
49+
} else {
50+
catalog.approveReviewItem(id);
51+
}
52+
res.json({ success: true });
53+
} catch (error) {
54+
res.status(500).json({ error: error.message });
55+
}
56+
});
57+
58+
app.post("/api/apply", async (req, res) => {
59+
try {
60+
const result = await applyApprovedReview({ catalog });
61+
res.json(result);
62+
} catch (error) {
63+
res.status(500).json({ error: error.message });
64+
}
65+
});
66+
67+
// Serve UI static files
68+
const uiPath = path.resolve("ui/dist");
69+
app.use(express.static(uiPath));
70+
71+
// Final fallback for React routing - using a middleware without a path to avoid regex issues
72+
app.use((req, res, next) => {
73+
// If it's an API call that didn't match, 404
74+
if (req.path.startsWith("/api")) {
75+
return res.status(404).json({ error: "API route not found" });
76+
}
77+
78+
// Otherwise serve index.html
79+
res.sendFile(path.join(uiPath, "index.html"), (err) => {
80+
if (err) {
81+
res.status(404).send("Nyx Dashboard UI not built yet. Run 'npm run ui:build' in the ui directory.");
82+
}
83+
});
84+
});
85+
86+
return new Promise((resolve) => {
87+
const server = app.listen(port, () => {
88+
console.log(`Nyx Dashboard running at http://localhost:${port}`);
89+
resolve(server);
90+
});
91+
});
92+
}

ui/.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?

0 commit comments

Comments
 (0)