datajunction-ui 0.0.103 → 0.0.104

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,11 +10,211 @@ import NodeListActions from '../../components/NodeListActions';
10
10
  import NamespaceHeader from '../../components/NamespaceHeader';
11
11
  import LoadingIcon from '../../icons/LoadingIcon';
12
12
  import CompactSelect from './CompactSelect';
13
+ import { NodeBadge, NodeLink } from '../../components/NodeComponents';
13
14
  import { getDJUrl } from '../../services/DJService';
14
15
 
16
+ const NODE_TYPE_ORDER = ['metric', 'cube', 'dimension', 'transform', 'source'];
17
+
18
+ const AVATAR_COLORS = [
19
+ ['#dbeafe', '#1e40af'], // blue
20
+ ['#dcfce7', '#166534'], // green
21
+ ['#fef3c7', '#92400e'], // amber
22
+ ['#fce7f3', '#9d174d'], // pink
23
+ ['#ede9fe', '#5b21b6'], // purple
24
+ ['#ffedd5', '#9a3412'], // orange
25
+ ['#fee2e2', '#991b1b'], // red
26
+ ['#d1fae5', '#065f46'], // teal
27
+ ];
28
+ function avatarColorIndex(username) {
29
+ let hash = 0;
30
+ for (let i = 0; i < username.length; i++) {
31
+ hash = (hash * 31 + username.charCodeAt(i)) >>> 0;
32
+ }
33
+ return hash % AVATAR_COLORS.length;
34
+ }
35
+ const MAX_PER_TYPE = 8;
36
+
37
+ const NODE_TYPE_COLORS = {
38
+ metric: { bg: '#fad7dd', color: '#a2283e' },
39
+ cube: { bg: '#dbafff', color: '#580076' },
40
+ dimension: { bg: '#ffefd0', color: '#a96621' },
41
+ transform: { bg: '#ccefff', color: '#0063b4' },
42
+ source: { bg: '#ccf7e5', color: '#00b368' },
43
+ };
44
+
45
+ function DefaultBranchPreview({ nodes, defaultBranchNs }) {
46
+ const groups = {};
47
+ nodes.forEach(node => {
48
+ const type = (node.type || 'unknown').toLowerCase();
49
+ if (!groups[type]) groups[type] = [];
50
+ groups[type].push(node);
51
+ });
52
+ const grouped = NODE_TYPE_ORDER.filter(t => groups[t]?.length > 0).map(t => ({
53
+ type: t,
54
+ nodes: groups[t],
55
+ }));
56
+
57
+ if (grouped.length === 0) return null;
58
+
59
+ return (
60
+ <div
61
+ style={{
62
+ display: 'grid',
63
+ gridTemplateColumns: '1fr 1fr',
64
+ gap: '0 0',
65
+ margin: '20px',
66
+ }}
67
+ >
68
+ {grouped.map(({ type, nodes: typeNodes }, idx) => {
69
+ const shown = typeNodes.slice(0, MAX_PER_TYPE);
70
+ const remaining = typeNodes.length - shown.length;
71
+ const isLeftCol = idx % 2 === 0;
72
+ return (
73
+ <div
74
+ key={type}
75
+ style={{
76
+ borderTop: '1px solid #e2e8f0',
77
+ paddingTop: '16px',
78
+ paddingBottom: '28px',
79
+ paddingRight: isLeftCol ? '32px' : '0',
80
+ paddingLeft: isLeftCol ? '0' : '32px',
81
+ borderLeft: isLeftCol ? 'none' : '1px solid #e2e8f0',
82
+ }}
83
+ >
84
+ <div
85
+ style={{
86
+ display: 'flex',
87
+ alignItems: 'center',
88
+ justifyContent: 'space-between',
89
+ marginBottom: '8px',
90
+ }}
91
+ >
92
+ <span
93
+ style={{
94
+ fontSize: '11px',
95
+ fontWeight: '700',
96
+ textTransform: 'uppercase',
97
+ letterSpacing: '0.6px',
98
+ color: '#64748b',
99
+ }}
100
+ >
101
+ {type}s
102
+ <span
103
+ style={{
104
+ marginLeft: '6px',
105
+ fontWeight: '600',
106
+ fontSize: '10px',
107
+ padding: '3px 7px',
108
+ backgroundColor: NODE_TYPE_COLORS[type]?.bg ?? '#f1f5f9',
109
+ color: NODE_TYPE_COLORS[type]?.color ?? '#475569',
110
+ borderRadius: '8px',
111
+ }}
112
+ >
113
+ {typeNodes.length}
114
+ </span>
115
+ </span>
116
+ {remaining > 0 && (
117
+ <a
118
+ href={`/namespaces/${defaultBranchNs}?type=${type}`}
119
+ style={{
120
+ fontSize: '11px',
121
+ color: '#3b82f6',
122
+ textDecoration: 'none',
123
+ fontWeight: '500',
124
+ }}
125
+ >
126
+ +{remaining} more →
127
+ </a>
128
+ )}
129
+ </div>
130
+ {shown.map((node, idx) => (
131
+ <div
132
+ key={node.name}
133
+ style={{
134
+ display: 'flex',
135
+ alignItems: 'center',
136
+ gap: '8px',
137
+ padding: '6px 0',
138
+ borderBottom:
139
+ idx < shown.length - 1 ? '1px solid #f1f5f9' : 'none',
140
+ }}
141
+ >
142
+ <NodeLink
143
+ node={node}
144
+ size="large"
145
+ ellipsis={true}
146
+ style={{ flex: 1, minWidth: 0 }}
147
+ />
148
+ {node.owners?.length > 0 && (
149
+ <div style={{ display: 'flex', gap: '2px', flexShrink: 0 }}>
150
+ {node.owners.slice(0, 3).map(owner => {
151
+ const initials = owner.username
152
+ .split('@')[0]
153
+ .slice(0, 2)
154
+ .toUpperCase();
155
+ const [bg, fg] =
156
+ AVATAR_COLORS[avatarColorIndex(owner.username)];
157
+ return (
158
+ <span
159
+ key={owner.username}
160
+ title={owner.username}
161
+ style={{
162
+ display: 'inline-flex',
163
+ alignItems: 'center',
164
+ justifyContent: 'center',
165
+ width: '26px',
166
+ height: '26px',
167
+ borderRadius: '50%',
168
+ backgroundColor: bg,
169
+ color: fg,
170
+ fontSize: '9px',
171
+ fontWeight: '600',
172
+ flexShrink: 0,
173
+ }}
174
+ >
175
+ {initials}
176
+ </span>
177
+ );
178
+ })}
179
+ </div>
180
+ )}
181
+ {node.current?.updatedAt && (
182
+ <span
183
+ style={{
184
+ fontSize: '11px',
185
+ color: '#94a3b8',
186
+ whiteSpace: 'nowrap',
187
+ }}
188
+ >
189
+ {formatRelativeTime(node.current.updatedAt)}
190
+ </span>
191
+ )}
192
+ </div>
193
+ ))}
194
+ </div>
195
+ );
196
+ })}
197
+ </div>
198
+ );
199
+ }
200
+
15
201
  import 'styles/node-list.css';
16
202
  import 'styles/sorted-table.css';
17
203
 
204
+ function formatRelativeTime(isoString) {
205
+ const seconds = Math.floor((Date.now() - new Date(isoString)) / 1000);
206
+ if (seconds < 60) return 'just now';
207
+ const minutes = Math.floor(seconds / 60);
208
+ if (minutes < 60) return `${minutes}m ago`;
209
+ const hours = Math.floor(minutes / 60);
210
+ if (hours < 24) return `${hours}h ago`;
211
+ const days = Math.floor(hours / 24);
212
+ if (days < 30) return `${days}d ago`;
213
+ const months = Math.floor(days / 30);
214
+ if (months < 12) return `${months}mo ago`;
215
+ return `${Math.floor(months / 12)}y ago`;
216
+ }
217
+
18
218
  export function NamespacePage() {
19
219
  const ASC = 'ascending';
20
220
  const DESC = 'descending';
@@ -172,6 +372,13 @@ export function NamespacePage() {
172
372
  // Use undefined to indicate "not yet loaded", null means "loaded but no config"
173
373
  const [gitConfig, setGitConfig] = useState(undefined);
174
374
 
375
+ // Branch landing state (for git-root namespaces)
376
+ const [branches, setBranches] = useState(null); // null = not yet fetched
377
+ const [branchesLoading, setBranchesLoading] = useState(false);
378
+ const [defaultBranchNodes, setDefaultBranchNodes] = useState([]);
379
+ const [defaultBranchNodesLoading, setDefaultBranchNodesLoading] =
380
+ useState(false);
381
+
175
382
  const [sortConfig, setSortConfig] = useState({
176
383
  key: 'updatedAt',
177
384
  direction: DESC,
@@ -187,7 +394,61 @@ export function NamespacePage() {
187
394
 
188
395
  // Only show edit/add controls once git config has loaded and namespace is not git-only
189
396
  const gitConfigLoaded = gitConfig !== undefined;
190
- const showEditControls = gitConfigLoaded && !gitConfig?.git_only;
397
+ const isGitRoot =
398
+ gitConfigLoaded &&
399
+ !!gitConfig?.github_repo_path &&
400
+ !gitConfig?.parent_namespace;
401
+ const isBranchNamespace = gitConfigLoaded && !!gitConfig?.parent_namespace;
402
+ const showEditControls =
403
+ gitConfigLoaded && !gitConfig?.git_only && !isGitRoot;
404
+
405
+ // Reset branches when namespace changes
406
+ useEffect(() => {
407
+ setBranches(null);
408
+ }, [namespace]);
409
+
410
+ // Fetch branches when this is a git-root namespace
411
+ useEffect(() => {
412
+ if (!isGitRoot) return;
413
+ setBranchesLoading(true);
414
+ djClient
415
+ .getNamespaceBranches(namespace)
416
+ .then(data => setBranches(data || []))
417
+ .catch(() => setBranches([]))
418
+ .finally(() => setBranchesLoading(false));
419
+ }, [djClient, namespace, isGitRoot]);
420
+
421
+ // Fetch default branch nodes for the TypeGroupGrid preview
422
+ useEffect(() => {
423
+ if (!isGitRoot || !gitConfig?.default_branch) return;
424
+ const defaultBranchNs = `${namespace}.${gitConfig.default_branch}`;
425
+ setDefaultBranchNodesLoading(true);
426
+ djClient
427
+ .listNodesForLanding(
428
+ defaultBranchNs,
429
+ [],
430
+ [],
431
+ null,
432
+ null,
433
+ null,
434
+ 200,
435
+ { key: 'updatedAt', direction: 'descending' },
436
+ null,
437
+ {},
438
+ )
439
+ .then(result => {
440
+ const nodes =
441
+ result?.data?.findNodesPaginated?.edges?.map(e => ({
442
+ ...e.node,
443
+ // TypeGroupGrid reads status/mode at top level; normalize from current
444
+ status: e.node.current?.status,
445
+ mode: e.node.current?.mode,
446
+ })) || [];
447
+ setDefaultBranchNodes(nodes);
448
+ })
449
+ .catch(() => setDefaultBranchNodes([]))
450
+ .finally(() => setDefaultBranchNodesLoading(false));
451
+ }, [djClient, namespace, isGitRoot, gitConfig?.default_branch]);
191
452
 
192
453
  const requestSort = key => {
193
454
  let direction = ASC;
@@ -361,7 +622,9 @@ export function NamespacePage() {
361
622
  <tr key={node.name}>
362
623
  <td>
363
624
  <a href={'/nodes/' + node.name} className="link-table">
364
- {node.name}
625
+ {isBranchNamespace && node.name.startsWith(namespace + '.')
626
+ ? node.name.slice(namespace.length + 1)
627
+ : node.name}
365
628
  </a>
366
629
  <span
367
630
  className="rounded-pill badge bg-secondary-soft"
@@ -479,301 +742,307 @@ export function NamespacePage() {
479
742
  marginBottom: '1rem',
480
743
  }}
481
744
  >
482
- <h2 style={{ margin: 0 }}>Explore</h2>
745
+ <h2 style={{ margin: 0 }}>Browse</h2>
483
746
  </div>
484
747
 
485
- {/* Unified Filter Bar */}
486
- <div
487
- style={{
488
- marginBottom: '1rem',
489
- padding: '1rem',
490
- backgroundColor: '#f8fafc',
491
- borderRadius: '8px',
492
- }}
493
- >
494
- {/* Top row: Quick presets + Clear all */}
748
+ {/* Unified Filter Bar — hidden on git-root branch landing */}
749
+ {!(isGitRoot && branches?.length > 0) && (
495
750
  <div
496
751
  style={{
497
- display: 'flex',
498
- alignItems: 'center',
499
- gap: '12px',
500
- marginBottom: '12px',
752
+ marginBottom: '1rem',
753
+ padding: '1rem',
754
+ backgroundColor: '#f8fafc',
755
+ borderRadius: '8px',
501
756
  }}
502
757
  >
758
+ {/* Top row: Quick presets + Clear all */}
503
759
  <div
504
- style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
760
+ style={{
761
+ display: 'flex',
762
+ alignItems: 'center',
763
+ gap: '12px',
764
+ marginBottom: '12px',
765
+ }}
505
766
  >
506
- <span
507
- style={{
508
- fontSize: '11px',
509
- fontWeight: '600',
510
- textTransform: 'uppercase',
511
- letterSpacing: '0.5px',
512
- color: '#64748b',
513
- }}
514
- >
515
- Quick
516
- </span>
517
- {presets.map(preset => (
518
- <button
519
- key={preset.id}
520
- onClick={() => applyPreset(preset)}
521
- style={{
522
- padding: '4px 10px',
523
- fontSize: '11px',
524
- border: '1px solid',
525
- borderColor: isPresetActive(preset) ? '#1976d2' : '#ddd',
526
- borderRadius: '12px',
527
- backgroundColor: isPresetActive(preset)
528
- ? '#e3f2fd'
529
- : 'white',
530
- color: isPresetActive(preset) ? '#1976d2' : '#666',
531
- cursor: 'pointer',
532
- fontWeight: isPresetActive(preset) ? '600' : '400',
533
- }}
534
- >
535
- {preset.label}
536
- </button>
537
- ))}
538
- {hasActiveFilters && (
539
- <button
540
- onClick={clearAllFilters}
541
- style={{
542
- padding: '4px 10px',
543
- fontSize: '11px',
544
- border: 'none',
545
- backgroundColor: 'transparent',
546
- color: '#dc3545',
547
- cursor: 'pointer',
548
- }}
549
- >
550
- Clear all ×
551
- </button>
552
- )}
553
- </div>
554
- </div>
555
-
556
- {/* Bottom row: Dropdowns */}
557
- <div
558
- style={{
559
- display: 'flex',
560
- alignItems: 'flex-end',
561
- gap: '12px',
562
- }}
563
- >
564
- <CompactSelect
565
- label="Type"
566
- name="type"
567
- options={typeOptions}
568
- value={filters.node_type}
569
- onChange={e =>
570
- updateFilters({ ...filters, node_type: e?.value || '' })
571
- }
572
- flex={1}
573
- minWidth="80px"
574
- testId="select-node-type"
575
- />
576
- <CompactSelect
577
- label="Tags"
578
- name="tags"
579
- options={tagOptions}
580
- value={filters.tags}
581
- onChange={e =>
582
- updateFilters({
583
- ...filters,
584
- tags: e ? e.map(t => t.value) : [],
585
- })
586
- }
587
- isMulti
588
- isLoading={tagsLoading}
589
- flex={1.5}
590
- minWidth="100px"
591
- testId="select-tag"
592
- />
593
- <CompactSelect
594
- label="Edited By"
595
- name="editedBy"
596
- options={userOptions}
597
- value={filters.edited_by}
598
- onChange={e =>
599
- updateFilters({ ...filters, edited_by: e?.value || '' })
600
- }
601
- isLoading={usersLoading}
602
- flex={1}
603
- minWidth="80px"
604
- testId="select-user"
605
- />
606
- <CompactSelect
607
- label="Mode"
608
- name="mode"
609
- options={modeOptions}
610
- value={filters.mode}
611
- onChange={e =>
612
- updateFilters({ ...filters, mode: e?.value || '' })
613
- }
614
- flex={1}
615
- minWidth="80px"
616
- />
617
- <CompactSelect
618
- label="Owner"
619
- name="owner"
620
- options={userOptions}
621
- value={filters.ownedBy}
622
- onChange={e =>
623
- updateFilters({ ...filters, ownedBy: e?.value || '' })
624
- }
625
- isLoading={usersLoading}
626
- flex={1}
627
- minWidth="80px"
628
- />
629
- <CompactSelect
630
- label="Status"
631
- name="status"
632
- options={statusOptions}
633
- value={filters.statuses}
634
- onChange={e =>
635
- updateFilters({ ...filters, statuses: e?.value || '' })
636
- }
637
- flex={1}
638
- minWidth="80px"
639
- />
640
-
641
- {/* More Filters (Quality) */}
642
- <div style={{ position: 'relative', flex: 0, minWidth: 'auto' }}>
643
767
  <div
644
- style={{
645
- display: 'flex',
646
- flexDirection: 'column',
647
- gap: '2px',
648
- }}
768
+ style={{ display: 'flex', alignItems: 'center', gap: '6px' }}
649
769
  >
650
- <label
770
+ <span
651
771
  style={{
652
- fontSize: '10px',
772
+ fontSize: '11px',
653
773
  fontWeight: '600',
654
- color: '#666',
655
774
  textTransform: 'uppercase',
656
775
  letterSpacing: '0.5px',
776
+ color: '#64748b',
657
777
  }}
658
778
  >
659
- Quality
660
- </label>
661
- <button
662
- onClick={() => setMoreFiltersOpen(!moreFiltersOpen)}
663
- style={{
664
- height: '32px',
665
- padding: '0 12px',
666
- fontSize: '12px',
667
- border: '1px solid #ccc',
668
- borderRadius: '4px',
669
- backgroundColor:
670
- moreFiltersCount > 0 ? '#e3f2fd' : 'white',
671
- color: '#666',
672
- cursor: 'pointer',
673
- display: 'flex',
674
- alignItems: 'center',
675
- gap: '4px',
676
- whiteSpace: 'nowrap',
677
- }}
678
- >
679
- {moreFiltersCount > 0
680
- ? `${moreFiltersCount} active`
681
- : 'Issues'}
682
- <span style={{ fontSize: '8px' }}>
683
- {moreFiltersOpen ? '▲' : '▼'}
684
- </span>
685
- </button>
779
+ Quick
780
+ </span>
781
+ {presets.map(preset => (
782
+ <button
783
+ key={preset.id}
784
+ onClick={() => applyPreset(preset)}
785
+ style={{
786
+ padding: '4px 10px',
787
+ fontSize: '11px',
788
+ border: '1px solid',
789
+ borderColor: isPresetActive(preset)
790
+ ? '#1976d2'
791
+ : '#ddd',
792
+ borderRadius: '12px',
793
+ backgroundColor: isPresetActive(preset)
794
+ ? '#e3f2fd'
795
+ : 'white',
796
+ color: isPresetActive(preset) ? '#1976d2' : '#666',
797
+ cursor: 'pointer',
798
+ fontWeight: isPresetActive(preset) ? '600' : '400',
799
+ }}
800
+ >
801
+ {preset.label}
802
+ </button>
803
+ ))}
804
+ {hasActiveFilters && (
805
+ <button
806
+ onClick={clearAllFilters}
807
+ style={{
808
+ padding: '4px 10px',
809
+ fontSize: '11px',
810
+ border: 'none',
811
+ backgroundColor: 'transparent',
812
+ color: '#dc3545',
813
+ cursor: 'pointer',
814
+ }}
815
+ >
816
+ Clear all ×
817
+ </button>
818
+ )}
686
819
  </div>
820
+ </div>
687
821
 
688
- {moreFiltersOpen && (
822
+ {/* Bottom row: Dropdowns */}
823
+ <div
824
+ style={{
825
+ display: 'flex',
826
+ alignItems: 'flex-end',
827
+ gap: '12px',
828
+ }}
829
+ >
830
+ <CompactSelect
831
+ label="Type"
832
+ name="type"
833
+ options={typeOptions}
834
+ value={filters.node_type}
835
+ onChange={e =>
836
+ updateFilters({ ...filters, node_type: e?.value || '' })
837
+ }
838
+ flex={1}
839
+ minWidth="80px"
840
+ testId="select-node-type"
841
+ />
842
+ <CompactSelect
843
+ label="Tags"
844
+ name="tags"
845
+ options={tagOptions}
846
+ value={filters.tags}
847
+ onChange={e =>
848
+ updateFilters({
849
+ ...filters,
850
+ tags: e ? e.map(t => t.value) : [],
851
+ })
852
+ }
853
+ isMulti
854
+ isLoading={tagsLoading}
855
+ flex={1.5}
856
+ minWidth="100px"
857
+ testId="select-tag"
858
+ />
859
+ <CompactSelect
860
+ label="Edited By"
861
+ name="editedBy"
862
+ options={userOptions}
863
+ value={filters.edited_by}
864
+ onChange={e =>
865
+ updateFilters({ ...filters, edited_by: e?.value || '' })
866
+ }
867
+ isLoading={usersLoading}
868
+ flex={1}
869
+ minWidth="80px"
870
+ testId="select-user"
871
+ />
872
+ <CompactSelect
873
+ label="Mode"
874
+ name="mode"
875
+ options={modeOptions}
876
+ value={filters.mode}
877
+ onChange={e =>
878
+ updateFilters({ ...filters, mode: e?.value || '' })
879
+ }
880
+ flex={1}
881
+ minWidth="80px"
882
+ />
883
+ <CompactSelect
884
+ label="Owner"
885
+ name="owner"
886
+ options={userOptions}
887
+ value={filters.ownedBy}
888
+ onChange={e =>
889
+ updateFilters({ ...filters, ownedBy: e?.value || '' })
890
+ }
891
+ isLoading={usersLoading}
892
+ flex={1}
893
+ minWidth="80px"
894
+ />
895
+ <CompactSelect
896
+ label="Status"
897
+ name="status"
898
+ options={statusOptions}
899
+ value={filters.statuses}
900
+ onChange={e =>
901
+ updateFilters({ ...filters, statuses: e?.value || '' })
902
+ }
903
+ flex={1}
904
+ minWidth="80px"
905
+ />
906
+
907
+ {/* More Filters (Quality) */}
908
+ <div
909
+ style={{ position: 'relative', flex: 0, minWidth: 'auto' }}
910
+ >
689
911
  <div
690
912
  style={{
691
- position: 'absolute',
692
- top: '100%',
693
- right: 0,
694
- marginTop: '4px',
695
- padding: '12px',
696
- backgroundColor: 'white',
697
- border: '1px solid #ddd',
698
- borderRadius: '8px',
699
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
700
- zIndex: 1000,
701
- minWidth: '200px',
913
+ display: 'flex',
914
+ flexDirection: 'column',
915
+ gap: '2px',
702
916
  }}
703
917
  >
704
918
  <label
705
919
  style={{
706
- display: 'flex',
707
- alignItems: 'center',
708
- gap: '8px',
709
- fontSize: '12px',
710
- color: '#444',
711
- marginBottom: '8px',
712
- cursor: 'pointer',
920
+ fontSize: '10px',
921
+ fontWeight: '600',
922
+ color: '#666',
923
+ textTransform: 'uppercase',
924
+ letterSpacing: '0.5px',
713
925
  }}
714
926
  >
715
- <input
716
- type="checkbox"
717
- checked={filters.missingDescription}
718
- onChange={e =>
719
- updateFilters({
720
- ...filters,
721
- missingDescription: e.target.checked,
722
- })
723
- }
724
- />
725
- Missing Description
927
+ Quality
726
928
  </label>
727
- <label
929
+ <button
930
+ onClick={() => setMoreFiltersOpen(!moreFiltersOpen)}
728
931
  style={{
729
- display: 'flex',
730
- alignItems: 'center',
731
- gap: '8px',
932
+ height: '32px',
933
+ padding: '0 12px',
732
934
  fontSize: '12px',
733
- color: '#444',
734
- marginBottom: '8px',
935
+ border: '1px solid #ccc',
936
+ borderRadius: '4px',
937
+ backgroundColor:
938
+ moreFiltersCount > 0 ? '#e3f2fd' : 'white',
939
+ color: '#666',
735
940
  cursor: 'pointer',
736
- }}
737
- >
738
- <input
739
- type="checkbox"
740
- checked={filters.orphanedDimension}
741
- onChange={e =>
742
- updateFilters({
743
- ...filters,
744
- orphanedDimension: e.target.checked,
745
- })
746
- }
747
- />
748
- Orphaned Dimensions
749
- </label>
750
- <label
751
- style={{
752
941
  display: 'flex',
753
942
  alignItems: 'center',
754
- gap: '8px',
755
- fontSize: '12px',
756
- color: '#444',
757
- cursor: 'pointer',
943
+ gap: '4px',
944
+ whiteSpace: 'nowrap',
758
945
  }}
759
946
  >
760
- <input
761
- type="checkbox"
762
- checked={filters.hasMaterialization}
763
- onChange={e =>
764
- updateFilters({
765
- ...filters,
766
- hasMaterialization: e.target.checked,
767
- })
768
- }
769
- />
770
- Has Materialization
771
- </label>
947
+ {moreFiltersCount > 0
948
+ ? `${moreFiltersCount} active`
949
+ : 'Issues'}
950
+ <span style={{ fontSize: '8px' }}>
951
+ {moreFiltersOpen ? '▲' : '▼'}
952
+ </span>
953
+ </button>
772
954
  </div>
773
- )}
955
+
956
+ {moreFiltersOpen && (
957
+ <div
958
+ style={{
959
+ position: 'absolute',
960
+ top: '100%',
961
+ right: 0,
962
+ marginTop: '4px',
963
+ padding: '12px',
964
+ backgroundColor: 'white',
965
+ border: '1px solid #ddd',
966
+ borderRadius: '8px',
967
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
968
+ zIndex: 1000,
969
+ minWidth: '200px',
970
+ }}
971
+ >
972
+ <label
973
+ style={{
974
+ display: 'flex',
975
+ alignItems: 'center',
976
+ gap: '8px',
977
+ fontSize: '12px',
978
+ color: '#444',
979
+ marginBottom: '8px',
980
+ cursor: 'pointer',
981
+ }}
982
+ >
983
+ <input
984
+ type="checkbox"
985
+ checked={filters.missingDescription}
986
+ onChange={e =>
987
+ updateFilters({
988
+ ...filters,
989
+ missingDescription: e.target.checked,
990
+ })
991
+ }
992
+ />
993
+ Missing Description
994
+ </label>
995
+ <label
996
+ style={{
997
+ display: 'flex',
998
+ alignItems: 'center',
999
+ gap: '8px',
1000
+ fontSize: '12px',
1001
+ color: '#444',
1002
+ marginBottom: '8px',
1003
+ cursor: 'pointer',
1004
+ }}
1005
+ >
1006
+ <input
1007
+ type="checkbox"
1008
+ checked={filters.orphanedDimension}
1009
+ onChange={e =>
1010
+ updateFilters({
1011
+ ...filters,
1012
+ orphanedDimension: e.target.checked,
1013
+ })
1014
+ }
1015
+ />
1016
+ Orphaned Dimensions
1017
+ </label>
1018
+ <label
1019
+ style={{
1020
+ display: 'flex',
1021
+ alignItems: 'center',
1022
+ gap: '8px',
1023
+ fontSize: '12px',
1024
+ color: '#444',
1025
+ cursor: 'pointer',
1026
+ }}
1027
+ >
1028
+ <input
1029
+ type="checkbox"
1030
+ checked={filters.hasMaterialization}
1031
+ onChange={e =>
1032
+ updateFilters({
1033
+ ...filters,
1034
+ hasMaterialization: e.target.checked,
1035
+ })
1036
+ }
1037
+ />
1038
+ Has Materialization
1039
+ </label>
1040
+ </div>
1041
+ )}
1042
+ </div>
774
1043
  </div>
775
1044
  </div>
776
- </div>
1045
+ )}
777
1046
 
778
1047
  <div className="table-responsive">
779
1048
  <div
@@ -860,44 +1129,367 @@ export function NamespacePage() {
860
1129
  </a>
861
1130
  {showEditControls && <AddNodeDropdown namespace={namespace} />}
862
1131
  </NamespaceHeader>
863
- <table className="card-table table" style={{ marginBottom: 0 }}>
864
- <thead>
865
- <tr>
866
- {fields.map(field => {
867
- const thStyle = {
868
- fontFamily:
869
- "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
870
- fontSize: '11px',
1132
+
1133
+ {/* Branch landing page for git-root namespaces */}
1134
+ {!gitConfigLoaded ? null : isGitRoot &&
1135
+ (branchesLoading || (branches && branches.length > 0)) ? (
1136
+ <div style={{ padding: '8px 0' }}>
1137
+ <div
1138
+ style={{
1139
+ display: 'flex',
1140
+ alignItems: 'center',
1141
+ gap: '8px',
1142
+ marginBottom: '16px',
1143
+ padding: '0 4px',
1144
+ }}
1145
+ >
1146
+ <svg
1147
+ xmlns="http://www.w3.org/2000/svg"
1148
+ width="14"
1149
+ height="14"
1150
+ viewBox="0 0 24 24"
1151
+ fill="none"
1152
+ stroke="#64748b"
1153
+ strokeWidth="2"
1154
+ strokeLinecap="round"
1155
+ strokeLinejoin="round"
1156
+ >
1157
+ <line x1="6" y1="3" x2="6" y2="15" />
1158
+ <circle cx="18" cy="6" r="3" />
1159
+ <circle cx="6" cy="18" r="3" />
1160
+ <path d="M18 9a9 9 0 0 1-9 9" />
1161
+ </svg>
1162
+ <span
1163
+ style={{
1164
+ fontSize: '12px',
871
1165
  fontWeight: '600',
872
1166
  textTransform: 'uppercase',
873
1167
  letterSpacing: '0.5px',
874
1168
  color: '#64748b',
875
- padding: '12px 16px',
876
- borderBottom: '1px solid #e2e8f0',
877
- backgroundColor: 'transparent',
878
- };
879
- return (
880
- <th key={field} style={thStyle}>
881
- <button
882
- type="button"
883
- onClick={() => requestSort(field)}
884
- className={'sortable ' + getClassNamesFor(field)}
1169
+ }}
1170
+ >
1171
+ Branches
1172
+ </span>
1173
+ {!branchesLoading && branches && (
1174
+ <span
1175
+ style={{
1176
+ fontSize: '11px',
1177
+ color: '#94a3b8',
1178
+ fontWeight: 400,
1179
+ }}
1180
+ >
1181
+ {branches.length}
1182
+ </span>
1183
+ )}
1184
+ </div>
1185
+
1186
+ {branchesLoading ? (
1187
+ <div
1188
+ style={{
1189
+ padding: '20px 4px',
1190
+ color: '#94a3b8',
1191
+ fontSize: '13px',
1192
+ }}
1193
+ >
1194
+ <LoadingIcon />
1195
+ </div>
1196
+ ) : (
1197
+ <div
1198
+ style={{
1199
+ display: 'grid',
1200
+ gridTemplateColumns:
1201
+ 'repeat(auto-fill, minmax(280px, 1fr))',
1202
+ gap: '12px',
1203
+ }}
1204
+ >
1205
+ {branches.map(b => {
1206
+ const isDefault =
1207
+ b.git_branch === gitConfig?.default_branch ||
1208
+ b.namespace ===
1209
+ `${namespace}.${gitConfig?.default_branch}`;
1210
+ return (
1211
+ <a
1212
+ key={b.namespace}
1213
+ href={`/namespaces/${b.namespace}`}
1214
+ style={{ textDecoration: 'none' }}
1215
+ >
1216
+ <div
1217
+ style={{
1218
+ padding: '14px 16px',
1219
+ border: `1px solid ${
1220
+ isDefault ? '#bfdbfe' : '#e2e8f0'
1221
+ }`,
1222
+ borderRadius: '8px',
1223
+ backgroundColor: isDefault
1224
+ ? '#f0f7ff'
1225
+ : '#ffffff',
1226
+ cursor: 'pointer',
1227
+ transition:
1228
+ 'box-shadow 0.15s ease, border-color 0.15s ease',
1229
+ }}
1230
+ onMouseOver={e => {
1231
+ e.currentTarget.style.boxShadow =
1232
+ '0 2px 8px rgba(0,0,0,0.08)';
1233
+ e.currentTarget.style.borderColor = isDefault
1234
+ ? '#93c5fd'
1235
+ : '#cbd5e1';
1236
+ }}
1237
+ onMouseOut={e => {
1238
+ e.currentTarget.style.boxShadow = 'none';
1239
+ e.currentTarget.style.borderColor = isDefault
1240
+ ? '#bfdbfe'
1241
+ : '#e2e8f0';
1242
+ }}
1243
+ >
1244
+ <div
1245
+ style={{
1246
+ display: 'flex',
1247
+ alignItems: 'center',
1248
+ justifyContent: 'space-between',
1249
+ marginBottom: '8px',
1250
+ }}
1251
+ >
1252
+ <div
1253
+ style={{
1254
+ display: 'flex',
1255
+ alignItems: 'center',
1256
+ gap: '6px',
1257
+ }}
1258
+ >
1259
+ <svg
1260
+ xmlns="http://www.w3.org/2000/svg"
1261
+ width="13"
1262
+ height="13"
1263
+ viewBox="0 0 24 24"
1264
+ fill="none"
1265
+ stroke={isDefault ? '#1e40af' : '#475569'}
1266
+ strokeWidth="2"
1267
+ strokeLinecap="round"
1268
+ strokeLinejoin="round"
1269
+ >
1270
+ <line x1="6" y1="3" x2="6" y2="15" />
1271
+ <circle cx="18" cy="6" r="3" />
1272
+ <circle cx="6" cy="18" r="3" />
1273
+ <path d="M18 9a9 9 0 0 1-9 9" />
1274
+ </svg>
1275
+ <span
1276
+ style={{
1277
+ fontWeight: '600',
1278
+ fontSize: '14px',
1279
+ color: isDefault ? '#1e40af' : '#1e293b',
1280
+ }}
1281
+ >
1282
+ {b.git_branch || b.namespace}
1283
+ </span>
1284
+ {isDefault && (
1285
+ <span
1286
+ style={{
1287
+ fontSize: '10px',
1288
+ padding: '1px 6px',
1289
+ backgroundColor: '#1e40af',
1290
+ color: 'white',
1291
+ borderRadius: '10px',
1292
+ fontWeight: '600',
1293
+ }}
1294
+ >
1295
+ default
1296
+ </span>
1297
+ )}
1298
+ </div>
1299
+ {b.git_only && (
1300
+ <span
1301
+ style={{
1302
+ fontSize: '10px',
1303
+ padding: '1px 6px',
1304
+ backgroundColor: '#fef3c7',
1305
+ color: '#92400e',
1306
+ borderRadius: '10px',
1307
+ }}
1308
+ >
1309
+ read-only
1310
+ </span>
1311
+ )}
1312
+ </div>
1313
+ <div
1314
+ style={{
1315
+ display: 'flex',
1316
+ alignItems: 'center',
1317
+ gap: '12px',
1318
+ fontSize: '12px',
1319
+ color: '#64748b',
1320
+ }}
1321
+ >
1322
+ <span
1323
+ style={{
1324
+ display: 'flex',
1325
+ alignItems: 'center',
1326
+ gap: '4px',
1327
+ }}
1328
+ >
1329
+ <svg
1330
+ xmlns="http://www.w3.org/2000/svg"
1331
+ width="11"
1332
+ height="11"
1333
+ viewBox="0 0 24 24"
1334
+ fill="none"
1335
+ stroke="currentColor"
1336
+ strokeWidth="2"
1337
+ strokeLinecap="round"
1338
+ strokeLinejoin="round"
1339
+ >
1340
+ <rect x="3" y="3" width="7" height="7" />
1341
+ <rect x="14" y="3" width="7" height="7" />
1342
+ <rect x="14" y="14" width="7" height="7" />
1343
+ <rect x="3" y="14" width="7" height="7" />
1344
+ </svg>
1345
+ {b.num_nodes} nodes
1346
+ </span>
1347
+ {b.invalid_node_count > 0 && (
1348
+ <span
1349
+ style={{
1350
+ display: 'flex',
1351
+ alignItems: 'center',
1352
+ gap: '3px',
1353
+ color: '#dc2626',
1354
+ }}
1355
+ >
1356
+ <svg
1357
+ xmlns="http://www.w3.org/2000/svg"
1358
+ width="11"
1359
+ height="11"
1360
+ viewBox="0 0 24 24"
1361
+ fill="none"
1362
+ stroke="currentColor"
1363
+ strokeWidth="2"
1364
+ strokeLinecap="round"
1365
+ strokeLinejoin="round"
1366
+ >
1367
+ <circle cx="12" cy="12" r="10" />
1368
+ <line x1="12" y1="8" x2="12" y2="12" />
1369
+ <line
1370
+ x1="12"
1371
+ y1="16"
1372
+ x2="12.01"
1373
+ y2="16"
1374
+ />
1375
+ </svg>
1376
+ {b.invalid_node_count} invalid
1377
+ </span>
1378
+ )}
1379
+ {b.last_deployed_at && (
1380
+ <span
1381
+ style={{
1382
+ display: 'flex',
1383
+ alignItems: 'center',
1384
+ gap: '3px',
1385
+ color: '#94a3b8',
1386
+ }}
1387
+ title={new Date(
1388
+ b.last_deployed_at,
1389
+ ).toLocaleString()}
1390
+ >
1391
+ <svg
1392
+ xmlns="http://www.w3.org/2000/svg"
1393
+ width="11"
1394
+ height="11"
1395
+ viewBox="0 0 24 24"
1396
+ fill="none"
1397
+ stroke="currentColor"
1398
+ strokeWidth="2"
1399
+ strokeLinecap="round"
1400
+ strokeLinejoin="round"
1401
+ >
1402
+ <circle cx="12" cy="12" r="10" />
1403
+ <polyline points="12 6 12 12 16 14" />
1404
+ </svg>
1405
+ {formatRelativeTime(b.last_deployed_at)}
1406
+ </span>
1407
+ )}
1408
+ </div>
1409
+ </div>
1410
+ </a>
1411
+ );
1412
+ })}
1413
+ </div>
1414
+ )}
1415
+
1416
+ {/* Default branch node preview grouped by type */}
1417
+ {gitConfig?.default_branch && (
1418
+ <div style={{ marginTop: '28px' }}>
1419
+ <div
1420
+ style={{
1421
+ borderTop: '1px solid #e2e8f0',
1422
+ marginBottom: '20px',
1423
+ }}
1424
+ />
1425
+ <div
1426
+ style={{
1427
+ display: 'flex',
1428
+ alignItems: 'center',
1429
+ justifyContent: 'space-between',
1430
+ marginBottom: '12px',
1431
+ padding: '0 4px',
1432
+ }}
1433
+ >
1434
+ <div
1435
+ style={{
1436
+ display: 'flex',
1437
+ alignItems: 'center',
1438
+ gap: '8px',
1439
+ }}
1440
+ >
1441
+ <span
885
1442
  style={{
886
- fontSize: 'inherit',
887
- fontWeight: 'inherit',
888
- letterSpacing: 'inherit',
889
- textTransform: 'inherit',
890
- fontFamily: 'inherit',
1443
+ fontSize: '12px',
1444
+ fontWeight: '600',
1445
+ textTransform: 'uppercase',
1446
+ letterSpacing: '0.5px',
1447
+ color: '#64748b',
891
1448
  }}
892
1449
  >
893
- {field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
894
- </button>
895
- </th>
896
- );
897
- })}
898
- {showEditControls && (
899
- <th
900
- style={{
1450
+ {gitConfig.default_branch}
1451
+ </span>
1452
+ <span
1453
+ style={{
1454
+ fontSize: '10px',
1455
+ padding: '1px 6px',
1456
+ backgroundColor: '#1e40af',
1457
+ color: 'white',
1458
+ borderRadius: '10px',
1459
+ fontWeight: '600',
1460
+ }}
1461
+ >
1462
+ default
1463
+ </span>
1464
+ </div>
1465
+ <a
1466
+ href={`/namespaces/${namespace}.${gitConfig.default_branch}`}
1467
+ style={{
1468
+ fontSize: '12px',
1469
+ color: '#3b82f6',
1470
+ textDecoration: 'none',
1471
+ }}
1472
+ >
1473
+ View all →
1474
+ </a>
1475
+ </div>
1476
+ {defaultBranchNodesLoading ? (
1477
+ <LoadingIcon />
1478
+ ) : (
1479
+ <DefaultBranchPreview
1480
+ nodes={defaultBranchNodes}
1481
+ defaultBranchNs={`${namespace}.${gitConfig.default_branch}`}
1482
+ />
1483
+ )}
1484
+ </div>
1485
+ )}
1486
+ </div>
1487
+ ) : (
1488
+ <table className="card-table table" style={{ marginBottom: 0 }}>
1489
+ <thead>
1490
+ <tr>
1491
+ {fields.map(field => {
1492
+ const thStyle = {
901
1493
  fontFamily:
902
1494
  "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
903
1495
  fontSize: '11px',
@@ -908,38 +1500,75 @@ export function NamespacePage() {
908
1500
  padding: '12px 16px',
909
1501
  borderBottom: '1px solid #e2e8f0',
910
1502
  backgroundColor: 'transparent',
911
- }}
912
- >
913
- Actions
914
- </th>
915
- )}
916
- </tr>
917
- </thead>
918
- <tbody className="nodes-table-body">{nodesList}</tbody>
919
- <tfoot>
920
- <tr>
921
- <td>
922
- {retrieved && hasPrevPage ? (
923
- <a
924
- onClick={loadPrev}
925
- className="previous round pagination"
1503
+ };
1504
+ return (
1505
+ <th key={field} style={thStyle}>
1506
+ <button
1507
+ type="button"
1508
+ onClick={() => requestSort(field)}
1509
+ className={'sortable ' + getClassNamesFor(field)}
1510
+ style={{
1511
+ fontSize: 'inherit',
1512
+ fontWeight: 'inherit',
1513
+ letterSpacing: 'inherit',
1514
+ textTransform: 'inherit',
1515
+ fontFamily: 'inherit',
1516
+ }}
1517
+ >
1518
+ {field.replace(/([a-z](?=[A-Z]))/g, '$1 ')}
1519
+ </button>
1520
+ </th>
1521
+ );
1522
+ })}
1523
+ {showEditControls && (
1524
+ <th
1525
+ style={{
1526
+ fontFamily:
1527
+ "'Inter', -apple-system, BlinkMacSystemFont, sans-serif",
1528
+ fontSize: '11px',
1529
+ fontWeight: '600',
1530
+ textTransform: 'uppercase',
1531
+ letterSpacing: '0.5px',
1532
+ color: '#64748b',
1533
+ padding: '12px 16px',
1534
+ borderBottom: '1px solid #e2e8f0',
1535
+ backgroundColor: 'transparent',
1536
+ }}
926
1537
  >
927
- ← Previous
928
- </a>
929
- ) : (
930
- ''
931
- )}
932
- {retrieved && hasNextPage ? (
933
- <a onClick={loadNext} className="next round pagination">
934
- Next →
935
- </a>
936
- ) : (
937
- ''
1538
+ Actions
1539
+ </th>
938
1540
  )}
939
- </td>
940
- </tr>
941
- </tfoot>
942
- </table>
1541
+ </tr>
1542
+ </thead>
1543
+ <tbody className="nodes-table-body">{nodesList}</tbody>
1544
+ <tfoot>
1545
+ <tr>
1546
+ <td>
1547
+ {retrieved && hasPrevPage ? (
1548
+ <a
1549
+ onClick={loadPrev}
1550
+ className="previous round pagination"
1551
+ >
1552
+ ← Previous
1553
+ </a>
1554
+ ) : (
1555
+ ''
1556
+ )}
1557
+ {retrieved && hasNextPage ? (
1558
+ <a
1559
+ onClick={loadNext}
1560
+ className="next round pagination"
1561
+ >
1562
+ Next →
1563
+ </a>
1564
+ ) : (
1565
+ ''
1566
+ )}
1567
+ </td>
1568
+ </tr>
1569
+ </tfoot>
1570
+ </table>
1571
+ )}
943
1572
  </div>
944
1573
  </div>
945
1574
  </div>