   640→		hasNextPage = len(nextJobs) == 1
   641→	}
   642→
   643→	return &model.JobResultList{Items: jobs, Count: &count, HasNextPage: &hasNextPage}, nil
   644→}
   645→
   646→// JobsStatistics is the resolver for the jobsStatistics field.
   647→func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) ([]*model.JobsStatistics, error) {
   648→	startOverall := time.Now()
   649→	var err error
   650→	var stats []*model.JobsStatistics
   651→
   652→	// Top Level Defaults
   653→	defaultDurationBins := "1h"
   654→	defaultMetricBins := 10
   655→
   656→	// Build requested fields map for selective column computation
   657→	statsFields := []string{"totalJobs", "totalUsers", "totalWalltime", "totalNodes", "totalCores",
   658→		"totalAccs", "totalNodeHours", "totalCoreHours", "totalAccHours", "runningJobs", "shortJobs"}
   659→	reqFields := make(map[string]bool, len(statsFields))
   660→	fetchedMainStats := false
   661→	for _, f := range statsFields {
   662→		if requireField(ctx, f) {
   663→			reqFields[f] = true
   664→			if f != "runningJobs" && f != "shortJobs" {
   665→				fetchedMainStats = true
   666→			}
   667→		}
   668→	}
   669→
   670→	if fetchedMainStats {
   671→		if groupBy == nil {
   672→			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
   673→		} else {
   674→			startGrouped := time.Now()
   675→			stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
   676→			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
   677→		}
   678→	} else {
   679→		stats = make([]*model.JobsStatistics, 0, 1)
   680→		stats = append(stats, &model.JobsStatistics{})
   681→	}
   682→
   683→	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
   684→	// Only run separate count queries if main stats were not fetched.
   685→	if !fetchedMainStats {
   686→		if groupBy != nil {
   687→			if requireField(ctx, "shortJobs") {
   688→				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "short")
   689→			}
   690→			if requireField(ctx, "runningJobs") {
   691→				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "running")
   692→			}
   693→		} else {
   694→			if requireField(ctx, "shortJobs") {
   695→				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "short")
   696→			}
   697→			if requireField(ctx, "runningJobs") {
   698→				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "running")
   699→			}
   700→		}
   701→	}
   702→
   703→	if err != nil {
   704→		return nil, err
   705→	}
   706→
   707→	if requireField(ctx, "histDuration") || requireField(ctx, "histNumNodes") || requireField(ctx, "histNumCores") || requireField(ctx, "histNumAccs") {
   708→
   709→		if numDurationBins == nil {
   710→			numDurationBins = &defaultDurationBins
   711→		}
   712→
   713→		if groupBy == nil {
   714→			stats[0], err = r.Repo.AddHistograms(ctx, filter, stats[0], numDurationBins)
   715→			if err != nil {
   716→				return nil, err
   717→			}
   718→		} else {
   719→			return nil, errors.New("histograms only implemented without groupBy argument")

		hasNextPage = len(nextJobs) == 1
	}

	return &model.JobResultList{Items: jobs, Count: &count, HasNextPage: &hasNextPage}, nil
}

// JobsStatistics is the resolver for the jobsStatistics field.
func (r *queryResolver) JobsStatistics(ctx context.Context, filter []*model.JobFilter, metrics []string, page *model.PageRequest, sortBy *model.SortByAggregate, groupBy *model.Aggregate, numDurationBins *string, numMetricBins *int) ([]*model.JobsStatistics, error) {
	startOverall := time.Now()
	var err error
	var stats []*model.JobsStatistics

	// Top Level Defaults
	defaultDurationBins := "1h"
	defaultMetricBins := 10

	// Build requested fields map for selective column computation
	statsFields := []string{"totalJobs", "totalUsers", "totalWalltime", "totalNodes", "totalCores",
		"totalAccs", "totalNodeHours", "totalCoreHours", "totalAccHours", "runningJobs", "shortJobs"}
	reqFields := make(map[string]bool, len(statsFields))
	fetchedMainStats := false
	for _, f := range statsFields {
		if requireField(ctx, f) {
			reqFields[f] = true
			if f != "runningJobs" && f != "shortJobs" {
				fetchedMainStats = true
			}
		}
	}

	if fetchedMainStats {
		if groupBy == nil {
			stats, err = r.Repo.JobsStats(ctx, filter, reqFields)
		} else {
			startGrouped := time.Now()
			stats, err = r.Repo.JobsStatsGrouped(ctx, filter, page, sortBy, groupBy, reqFields)
			cclog.Infof("Timer JobsStatsGrouped call: %s", time.Since(startGrouped))
		}
	} else {
		stats = make([]*model.JobsStatistics, 0, 1)
		stats = append(stats, &model.JobsStatistics{})
	}

	// runningJobs and shortJobs are already inlined in JobsStats/JobsStatsGrouped.
	// Only run separate count queries if main stats were not fetched.
	if !fetchedMainStats {
		if groupBy != nil {
			if requireField(ctx, "shortJobs") {
				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "short")
			}
			if requireField(ctx, "runningJobs") {
				stats, err = r.Repo.AddJobCountGrouped(ctx, filter, groupBy, stats, "running")
			}
		} else {
			if requireField(ctx, "shortJobs") {
				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "short")
			}
			if requireField(ctx, "runningJobs") {
				stats, err = r.Repo.AddJobCount(ctx, filter, stats, "running")
			}
		}
	}

	if err != nil {
		return nil, err
	}

	if requireField(ctx, "histDuration") || requireField(ctx, "histNumNodes") || requireField(ctx, "histNumCores") || requireField(ctx, "histNumAccs") {

		if numDurationBins == nil {
			numDurationBins = &defaultDurationBins
		}

		if groupBy == nil {
			stats[0], err = r.Repo.AddHistograms(ctx, filter, stats[0], numDurationBins)
			if err != nil {
				return nil, err
			}
		} else {
			return nil, errors.New("histograms only implemented without groupBy argument")


   178→// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
   179→// them to the query. Supports filtering by job properties (cluster, state, user),
   180→// time ranges, resource usage, tags, and JSON field searches in meta_data,
   181→// footprint, and resources columns.
   182→func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
   183→	// Primary Key
   184→	if filter.DbID != nil {
   185→		dbIDs := make([]string, len(filter.DbID))
   186→		copy(dbIDs, filter.DbID)
   187→		query = query.Where(sq.Eq{"job.id": dbIDs})
   188→	}
   189→	// Explicit indices
   190→	if filter.Cluster != nil {
   191→		query = buildStringCondition("job.cluster", filter.Cluster, query)
   192→	}
   193→	if filter.SubCluster != nil {
   194→		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
   195→	}
   196→	if filter.Partition != nil {
   197→		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
   198→	}
   199→	if filter.State != nil {
   200→		states := make([]string, len(filter.State))
   201→		for i, val := range filter.State {
   202→			states[i] = string(val)
   203→		}
   204→		query = query.Where(sq.Eq{"job.job_state": states})
   205→	}
   206→	if filter.Shared != nil {
   207→		query = query.Where("job.shared = ?", *filter.Shared)
   208→	}
   209→	if filter.Project != nil {
   210→		query = buildStringCondition("job.project", filter.Project, query)
   211→	}
   212→	if filter.User != nil {
   213→		query = buildStringCondition("job.hpc_user", filter.User, query)
   214→	}
   215→	if filter.NumNodes != nil {
   216→		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
   217→	}
   218→	if filter.NumAccelerators != nil {
   219→		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
   220→	}
   221→	if filter.NumHWThreads != nil {
   222→		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
   223→	}
   224→	if filter.ArrayJobID != nil {
   225→		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
   226→	}
   227→	if filter.StartTime != nil {
   228→		query = buildTimeCondition("job.start_time", filter.StartTime, query)
   229→	}
   230→	if filter.Duration != nil {
   231→		query = buildIntCondition("job.duration", filter.Duration, query)
   232→	}
   233→	if filter.Energy != nil {
   234→		query = buildFloatCondition("job.energy", filter.Energy, query)
   235→	}
   236→	// Indices on Tag Table
   237→	if filter.Tags != nil {
   238→		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
   239→		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
   240→	}
   241→	// No explicit Indices
   242→	if filter.JobID != nil {
   243→		query = buildStringCondition("job.job_id", filter.JobID, query)
   244→	}
   245→	// Queries Within JSONs
   246→	if filter.MetricStats != nil {
   247→		for _, ms := range filter.MetricStats {
   248→			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
   249→		}
   250→	}
   251→	if filter.Node != nil {
   252→		query = buildResourceJSONCondition("hostname", filter.Node, query)
   253→	}
   254→	if filter.JobName != nil {
   255→		query = buildMetaJSONCondition("jobName", filter.JobName, query)
   256→	}
   257→	if filter.Schedule != nil {
   258→		interactiveJobname := "interactive"
   259→		switch *filter.Schedule {
   260→		case "interactive":
   261→			iFilter := model.StringInput{Eq: &interactiveJobname}
   262→			query = buildMetaJSONCondition("jobName", &iFilter, query)
   263→		case "batch":
   264→			sFilter := model.StringInput{Neq: &interactiveJobname}
   265→			query = buildMetaJSONCondition("jobName", &sFilter, query)
   266→		}
   267→	}
   268→
   269→	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
   270→	if filter.MinRunningFor != nil {
   271→		now := time.Now().Unix()
   272→		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
   273→		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
   274→		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
   275→	}
   276→	return query
   277→}
   278→
   279→// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
   280→func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
   281→	if cond.From != 1 && cond.To != 0 {
   282→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   283→	} else if cond.From != 1 && cond.To == 0 {
   284→		return query.Where(field+" >= ?", cond.From)
   285→	} else if cond.From == 1 && cond.To != 0 {
   286→		return query.Where(field+" <= ?", cond.To)
   287→	} else {
   288→		return query
   289→	}
   290→}
   291→
   292→// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
   293→func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   294→	if cond.From != 1.0 && cond.To != 0.0 {
   295→		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
   296→	} else if cond.From != 1.0 && cond.To == 0.0 {
   297→		return query.Where(field+" >= ?", cond.From)
   298→	} else if cond.From == 1.0 && cond.To != 0.0 {
   299→		return query.Where(field+" <= ?", cond.To)
   300→	} else {
   301→		return query
   302→	}
   303→}
   304→
   305→// buildTimeCondition creates time range filters supporting absolute timestamps,
   306→// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
   307→// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
   308→func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
   309→	if cond.From != nil && cond.To != nil {
   310→		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
   311→	} else if cond.From != nil {
   312→		return query.Where(field+" >= ?", cond.From.Unix())
   313→	} else if cond.To != nil {
   314→		return query.Where(field+" <= ?", cond.To.Unix())
   315→	} else if cond.Range != "" {
   316→		now := time.Now().Unix()
   317→		var then int64
   318→		switch cond.Range {
   319→		case "last6h":
   320→			then = now - (60 * 60 * 6)
   321→		case "last24h":
   322→			then = now - (60 * 60 * 24)
   323→		case "last7d":
   324→			then = now - (60 * 60 * 24 * 7)
   325→		case "last30d":
   326→			then = now - (60 * 60 * 24 * 30)
   327→		default:
   328→			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
   329→			return query
   330→		}
   331→		return query.Where(field+" >= ?", then)
   332→	} else {
   333→		return query
   334→	}
   335→}
   336→
   337→// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
   338→func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
   339→	query = query.Where("JSON_VALID(footprint)")
   340→	if cond.From != 1.0 && cond.To != 0.0 {
   341→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
   342→	} else if cond.From != 1.0 && cond.To == 0.0 {
   343→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
   344→	} else if cond.From == 1.0 && cond.To != 0.0 {
   345→		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
   346→	} else {
   347→		return query

// BuildWhereClause constructs SQL WHERE conditions from a JobFilter and applies
// them to the query. Supports filtering by job properties (cluster, state, user),
// time ranges, resource usage, tags, and JSON field searches in meta_data,
// footprint, and resources columns.
func BuildWhereClause(filter *model.JobFilter, query sq.SelectBuilder) sq.SelectBuilder {
	// Primary Key
	if filter.DbID != nil {
		dbIDs := make([]string, len(filter.DbID))
		copy(dbIDs, filter.DbID)
		query = query.Where(sq.Eq{"job.id": dbIDs})
	}
	// Explicit indices
	if filter.Cluster != nil {
		query = buildStringCondition("job.cluster", filter.Cluster, query)
	}
	if filter.SubCluster != nil {
		query = buildStringCondition("job.subcluster", filter.SubCluster, query)
	}
	if filter.Partition != nil {
		query = buildStringCondition("job.cluster_partition", filter.Partition, query)
	}
	if filter.State != nil {
		states := make([]string, len(filter.State))
		for i, val := range filter.State {
			states[i] = string(val)
		}
		query = query.Where(sq.Eq{"job.job_state": states})
	}
	if filter.Shared != nil {
		query = query.Where("job.shared = ?", *filter.Shared)
	}
	if filter.Project != nil {
		query = buildStringCondition("job.project", filter.Project, query)
	}
	if filter.User != nil {
		query = buildStringCondition("job.hpc_user", filter.User, query)
	}
	if filter.NumNodes != nil {
		query = buildIntCondition("job.num_nodes", filter.NumNodes, query)
	}
	if filter.NumAccelerators != nil {
		query = buildIntCondition("job.num_acc", filter.NumAccelerators, query)
	}
	if filter.NumHWThreads != nil {
		query = buildIntCondition("job.num_hwthreads", filter.NumHWThreads, query)
	}
	if filter.ArrayJobID != nil {
		query = query.Where("job.array_job_id = ?", *filter.ArrayJobID)
	}
	if filter.StartTime != nil {
		query = buildTimeCondition("job.start_time", filter.StartTime, query)
	}
	if filter.Duration != nil {
		query = buildIntCondition("job.duration", filter.Duration, query)
	}
	if filter.Energy != nil {
		query = buildFloatCondition("job.energy", filter.Energy, query)
	}
	// Indices on Tag Table
	if filter.Tags != nil {
		// This is an OR-Logic query: Returns all distinct jobs with at least one of the requested tags; TODO: AND-Logic query?
		query = query.Join("jobtag ON jobtag.job_id = job.id").Where(sq.Eq{"jobtag.tag_id": filter.Tags}).Distinct()
	}
	// No explicit Indices
	if filter.JobID != nil {
		query = buildStringCondition("job.job_id", filter.JobID, query)
	}
	// Queries Within JSONs
	if filter.MetricStats != nil {
		for _, ms := range filter.MetricStats {
			query = buildFloatJSONCondition(ms.MetricName, ms.Range, query)
		}
	}
	if filter.Node != nil {
		query = buildResourceJSONCondition("hostname", filter.Node, query)
	}
	if filter.JobName != nil {
		query = buildMetaJSONCondition("jobName", filter.JobName, query)
	}
	if filter.Schedule != nil {
		interactiveJobname := "interactive"
		switch *filter.Schedule {
		case "interactive":
			iFilter := model.StringInput{Eq: &interactiveJobname}
			query = buildMetaJSONCondition("jobName", &iFilter, query)
		case "batch":
			sFilter := model.StringInput{Neq: &interactiveJobname}
			query = buildMetaJSONCondition("jobName", &sFilter, query)
		}
	}

	// Configurable Filter to exclude recently started jobs, see config.go: ShortRunningJobsDuration
	if filter.MinRunningFor != nil {
		now := time.Now().Unix()
		// Only jobs whose start timestamp is more than MinRunningFor seconds in the past
		// If a job completed within the configured timeframe, it will still show up after the start_time matches the condition!
		query = query.Where(sq.Lt{"job.start_time": (now - int64(*filter.MinRunningFor))})
	}
	return query
}

// buildIntCondition creates clauses for integer range filters, using BETWEEN only if required.
func buildIntCondition(field string, cond *config.IntRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 1 && cond.To != 0 {
		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1 && cond.To == 0 {
		return query.Where(field+" >= ?", cond.From)
	} else if cond.From == 1 && cond.To != 0 {
		return query.Where(field+" <= ?", cond.To)
	} else {
		return query
	}
}

// buildFloatCondition creates a clauses for float range filters, using BETWEEN only if required.
func buildFloatCondition(field string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != 1.0 && cond.To != 0.0 {
		return query.Where(field+" BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where(field+" >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where(field+" <= ?", cond.To)
	} else {
		return query
	}
}

// buildTimeCondition creates time range filters supporting absolute timestamps,
// relative time ranges (last6h, last24h, last7d, last30d), or open-ended ranges.
// Reminder: BETWEEN Queries are slower and dont use indices as frequently: Only use if both conditions required
func buildTimeCondition(field string, cond *config.TimeRange, query sq.SelectBuilder) sq.SelectBuilder {
	if cond.From != nil && cond.To != nil {
		return query.Where(field+" BETWEEN ? AND ?", cond.From.Unix(), cond.To.Unix())
	} else if cond.From != nil {
		return query.Where(field+" >= ?", cond.From.Unix())
	} else if cond.To != nil {
		return query.Where(field+" <= ?", cond.To.Unix())
	} else if cond.Range != "" {
		now := time.Now().Unix()
		var then int64
		switch cond.Range {
		case "last6h":
			then = now - (60 * 60 * 6)
		case "last24h":
			then = now - (60 * 60 * 24)
		case "last7d":
			then = now - (60 * 60 * 24 * 7)
		case "last30d":
			then = now - (60 * 60 * 24 * 30)
		default:
			cclog.Debugf("No known named timeRange: startTime.range = %s", cond.Range)
			return query
		}
		return query.Where(field+" >= ?", then)
	} else {
		return query
	}
}

// buildFloatJSONCondition creates a filter on a numeric field within the footprint JSON column, using BETWEEN only if required.
func buildFloatJSONCondition(jsonField string, cond *model.FloatRange, query sq.SelectBuilder) sq.SelectBuilder {
	query = query.Where("JSON_VALID(footprint)")
	if cond.From != 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") BETWEEN ? AND ?", cond.From, cond.To)
	} else if cond.From != 1.0 && cond.To == 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") >= ?", cond.From)
	} else if cond.From == 1.0 && cond.To != 0.0 {
		return query.Where("JSON_EXTRACT(footprint, \"$."+jsonField+"\") <= ?", cond.To)
	} else {
		return query


Now let me check what the frontend actually requests to understand which fields are needed.


    70→  const paginatedRows = $derived(
    71→    sortedRows.slice((page - 1) * itemsPerPage, page * itemsPerPage)
    72→  );
    73→
    74→  /* Reset page when sorting or filter changes */
    75→  $effect(() => { sorting; nameFilter; page = 1; });
    76→
    77→  let stats = $derived(
    78→    queryStore({
    79→      client: client,
    80→      query: gql`
    81→        query($jobFilters: [JobFilter!]!, $fetchRunning: Boolean!) {
    82→          rows: jobsStatistics(filter: $jobFilters, groupBy: ${type}) {
    83→            id
    84→            name
    85→            totalJobs
    86→            shortJobs
    87→            totalCores @include(if: $fetchRunning)
    88→            totalAccs @include(if: $fetchRunning)
    89→            totalWalltime
    90→            totalCoreHours
    91→            totalAccHours
    92→          }
    93→        }`,
    94→      variables: {
    95→        jobFilters,
    96→        fetchRunning
    97→      },
    98→    })
    99→  );
   100→
   101→  /* Functions */
   102→  function exportCsv() {
   103→    const isUser = type === "USER";
   104→    const header = [
   105→      isUser ? "Username" : "Project",
   106→      ...(isUser ? ["Name"] : []),
   107→      "Total Jobs",
   108→      "Short Jobs",
   109→      ...(fetchRunning ? ["Total Cores", "Total Accelerators"] : []),
   110→      "Total Walltime",
   111→      "Total Core Hours",
   112→      "Total Accelerator Hours",
   113→    ];
   114→    const rows = sortedRows.map((row) => [
   115→      row.id,
   116→      ...(isUser ? [row?.name ?? ""] : []),
   117→      row.totalJobs,
   118→      row.shortJobs,
   119→      ...(fetchRunning ? [row.totalCores, row.totalAccs] : []),

  const paginatedRows = $derived(
    sortedRows.slice((page - 1) * itemsPerPage, page * itemsPerPage)
  );

  /* Reset page when sorting or filter changes */
  $effect(() => { sorting; nameFilter; page = 1; });

  let stats = $derived(
    queryStore({
      client: client,
      query: gql`
        query($jobFilters: [JobFilter!]!, $fetchRunning: Boolean!) {
          rows: jobsStatistics(filter: $jobFilters, groupBy: ${type}) {
            id
            name
            totalJobs
            shortJobs
            totalCores @include(if: $fetchRunning)
            totalAccs @include(if: $fetchRunning)
            totalWalltime
            totalCoreHours
            totalAccHours
          }
        }`,
      variables: {
        jobFilters,
        fetchRunning
      },
    })
  );

  /* Functions */
  function exportCsv() {
    const isUser = type === "USER";
    const header = [
      isUser ? "Username" : "Project",
      ...(isUser ? ["Name"] : []),
      "Total Jobs",
      "Short Jobs",
      ...(fetchRunning ? ["Total Cores", "Total Accelerators"] : []),
      "Total Walltime",
      "Total Core Hours",
      "Total Accelerator Hours",
    ];
    const rows = sortedRows.map((row) => [
      row.id,
      ...(isUser ? [row?.name ?? ""] : []),
      row.totalJobs,
      row.shortJobs,
      ...(fetchRunning ? [row.totalCores, row.totalAccs] : []),


     1→-- Migration 11: Remove overly specific table indexes formerly used in sorting
     2→-- When one or two indexed columns are used, sorting usually is fast
     3→-- Reduces from ~78 indexes to 48 for better write performance,
     4→-- reduced disk usage, and more reliable query planner decisions.
     5→-- Requires ANALYZE to be run after migration (done automatically on startup).
     6→
     7→-- ============================================================
     8→-- Drop SELECTED existing job indexes (from migrations 08/09)
     9→-- sqlite_autoindex_job_1 (UNIQUE constraint) is kept automatically
    10→-- ============================================================
    11→
    12→-- Cluster+Partition Filter Sorting
    13→DROP INDEX IF EXISTS jobs_cluster_partition_numnodes;
    14→DROP INDEX IF EXISTS jobs_cluster_partition_numhwthreads;
    15→DROP INDEX IF EXISTS jobs_cluster_partition_numacc;
    16→DROP INDEX IF EXISTS jobs_cluster_partition_energy;
    17→
    18→-- Cluster+JobState Filter Sorting
    19→DROP INDEX IF EXISTS jobs_cluster_jobstate_numnodes;
    20→DROP INDEX IF EXISTS jobs_cluster_jobstate_numhwthreads;
    21→DROP INDEX IF EXISTS jobs_cluster_jobstate_numacc;
    22→DROP INDEX IF EXISTS jobs_cluster_jobstate_energy;
    23→
    24→-- Cluster+Shared Filter Sorting
    25→DROP INDEX IF EXISTS jobs_cluster_shared_numnodes;
    26→DROP INDEX IF EXISTS jobs_cluster_shared_numhwthreads;
    27→DROP INDEX IF EXISTS jobs_cluster_shared_numacc;
    28→DROP INDEX IF EXISTS jobs_cluster_shared_energy;
    29→
    30→-- User Filter Sorting
    31→DROP INDEX IF EXISTS jobs_user_numnodes;
    32→DROP INDEX IF EXISTS jobs_user_numhwthreads;
    33→DROP INDEX IF EXISTS jobs_user_numacc;
    34→DROP INDEX IF EXISTS jobs_user_energy;
    35→
    36→-- Project Filter Sorting
    37→DROP INDEX IF EXISTS jobs_project_numnodes;
    38→DROP INDEX IF EXISTS jobs_project_numhwthreads;
    39→DROP INDEX IF EXISTS jobs_project_numacc;
    40→DROP INDEX IF EXISTS jobs_project_energy;
    41→
    42→-- JobState Filter Sorting
    43→DROP INDEX IF EXISTS jobs_jobstate_numnodes;
    44→DROP INDEX IF EXISTS jobs_jobstate_numhwthreads;
    45→DROP INDEX IF EXISTS jobs_jobstate_numacc;
    46→DROP INDEX IF EXISTS jobs_jobstate_energy;
    47→
    48→-- Shared Filter Sorting
    49→DROP INDEX IF EXISTS jobs_shared_numnodes;
    50→DROP INDEX IF EXISTS jobs_shared_numhwthreads;
    51→DROP INDEX IF EXISTS jobs_shared_numacc;
    52→DROP INDEX IF EXISTS jobs_shared_energy;
    53→
    54→-- ArrayJob Filter
    55→DROP INDEX IF EXISTS jobs_cluster_arrayjobid_starttime;
    56→
    57→-- Backup Indices For High Variety Columns
    58→DROP INDEX IF EXISTS jobs_duration;
    59→
    60→-- Optimize DB index usage
    61→PRAGMA optimize;
    62→

-- Migration 11: Remove overly specific table indexes formerly used in sorting
-- When one or two indexed columns are used, sorting usually is fast
-- Reduces from ~78 indexes to 48 for better write performance,
-- reduced disk usage, and more reliable query planner decisions.
-- Requires ANALYZE to be run after migration (done automatically on startup).

-- ============================================================
-- Drop SELECTED existing job indexes (from migrations 08/09)
-- sqlite_autoindex_job_1 (UNIQUE constraint) is kept automatically
-- ============================================================

-- Cluster+Partition Filter Sorting
DROP INDEX IF EXISTS jobs_cluster_partition_numnodes;
DROP INDEX IF EXISTS jobs_cluster_partition_numhwthreads;
DROP INDEX IF EXISTS jobs_cluster_partition_numacc;
DROP INDEX IF EXISTS jobs_cluster_partition_energy;

-- Cluster+JobState Filter Sorting
DROP INDEX IF EXISTS jobs_cluster_jobstate_numnodes;
DROP INDEX IF EXISTS jobs_cluster_jobstate_numhwthreads;
DROP INDEX IF EXISTS jobs_cluster_jobstate_numacc;
DROP INDEX IF EXISTS jobs_cluster_jobstate_energy;

-- Cluster+Shared Filter Sorting
DROP INDEX IF EXISTS jobs_cluster_shared_numnodes;
DROP INDEX IF EXISTS jobs_cluster_shared_numhwthreads;
DROP INDEX IF EXISTS jobs_cluster_shared_numacc;
DROP INDEX IF EXISTS jobs_cluster_shared_energy;

-- User Filter Sorting
DROP INDEX IF EXISTS jobs_user_numnodes;
DROP INDEX IF EXISTS jobs_user_numhwthreads;
DROP INDEX IF EXISTS jobs_user_numacc;
DROP INDEX IF EXISTS jobs_user_energy;

-- Project Filter Sorting
DROP INDEX IF EXISTS jobs_project_numnodes;
DROP INDEX IF EXISTS jobs_project_numhwthreads;
DROP INDEX IF EXISTS jobs_project_numacc;
DROP INDEX IF EXISTS jobs_project_energy;

-- JobState Filter Sorting
DROP INDEX IF EXISTS jobs_jobstate_numnodes;
DROP INDEX IF EXISTS jobs_jobstate_numhwthreads;
DROP INDEX IF EXISTS jobs_jobstate_numacc;
DROP INDEX IF EXISTS jobs_jobstate_energy;

-- Shared Filter Sorting
DROP INDEX IF EXISTS jobs_shared_numnodes;
DROP INDEX IF EXISTS jobs_shared_numhwthreads;
DROP INDEX IF EXISTS jobs_shared_numacc;
DROP INDEX IF EXISTS jobs_shared_energy;

-- ArrayJob Filter
DROP INDEX IF EXISTS jobs_cluster_arrayjobid_starttime;

-- Backup Indices For High Variety Columns
DROP INDEX IF EXISTS jobs_duration;

-- Optimize DB index usage
PRAGMA optimize;


Let me check the current state of the modified files and database connection setup.


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package repository
     7→
     8→import (
     9→	"database/sql"
    10→	"fmt"
    11→	"net/url"
    12→	"sync"
    13→	"time"
    14→
    15→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    16→	"github.com/jmoiron/sqlx"
    17→	"github.com/mattn/go-sqlite3"
    18→	"github.com/qustavo/sqlhooks/v2"
    19→)
    20→
    21→var (
    22→	dbConnOnce     sync.Once
    23→	dbConnInstance *DBConnection
    24→)
    25→
    26→type DBConnection struct {
    27→	DB     *sqlx.DB
    28→	Driver string
    29→}
    30→
    31→type DatabaseOptions struct {
    32→	URL                   string
    33→	MaxOpenConnections    int
    34→	MaxIdleConnections    int
    35→	ConnectionMaxLifetime time.Duration
    36→	ConnectionMaxIdleTime time.Duration
    37→}
    38→
    39→func setupSqlite(db *sql.DB, cfg *RepositoryConfig) error {
    40→	pragmas := []string{
    41→		"temp_store = memory",
    42→		fmt.Sprintf("soft_heap_limit = %d", int64(cfg.DbSoftHeapLimitMB)*1024*1024),
    43→	}
    44→
    45→	for _, pragma := range pragmas {
    46→		_, err := db.Exec("PRAGMA " + pragma)
    47→		if err != nil {
    48→			return err
    49→		}
    50→	}
    51→
    52→	return nil
    53→}
    54→
    55→func Connect(db string) {
    56→	var err error
    57→	var dbHandle *sqlx.DB
    58→
    59→	dbConnOnce.Do(func() {
    60→		opts := DatabaseOptions{
    61→			URL:                   db,
    62→			MaxOpenConnections:    repoConfig.MaxOpenConnections,
    63→			MaxIdleConnections:    repoConfig.MaxIdleConnections,
    64→			ConnectionMaxLifetime: repoConfig.ConnectionMaxLifetime,
    65→			ConnectionMaxIdleTime: repoConfig.ConnectionMaxIdleTime,
    66→		}
    67→
    68→		// TODO: Have separate DB handles for Writes and Reads
    69→		// Optimize SQLite connection: https://kerkour.com/sqlite-for-servers
    70→		connectionURLParams := make(url.Values)
    71→		connectionURLParams.Add("_txlock", "immediate")
    72→		connectionURLParams.Add("_journal_mode", "WAL")
    73→		connectionURLParams.Add("_busy_timeout", "5000")
    74→		connectionURLParams.Add("_synchronous", "NORMAL")
    75→		cacheSizeKiB := repoConfig.DbCacheSizeMB * 1024 // Convert MB to KiB
    76→		connectionURLParams.Add("_cache_size", fmt.Sprintf("-%d", cacheSizeKiB))
    77→		connectionURLParams.Add("_foreign_keys", "true")
    78→		opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionURLParams.Encode())
    79→
    80→		if cclog.Loglevel() == "debug" {
    81→			sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{}))
    82→			dbHandle, err = sqlx.Open("sqlite3WithHooks", opts.URL)
    83→		} else {
    84→			dbHandle, err = sqlx.Open("sqlite3", opts.URL)
    85→		}
    86→
    87→		if err != nil {
    88→			cclog.Abortf("DB Connection: Could not connect to SQLite database with sqlx.Open().\nError: %s\n", err.Error())
    89→		}
    90→
    91→		err = setupSqlite(dbHandle.DB, repoConfig)
    92→		if err != nil {
    93→			cclog.Abortf("Failed sqlite db setup.\nError: %s\n", err.Error())
    94→		}
    95→
    96→		cclog.Infof("SQLite config: cache_size=%dMB/conn, soft_heap_limit=%dMB, max_conns=%d",
    97→			repoConfig.DbCacheSizeMB, repoConfig.DbSoftHeapLimitMB, repoConfig.MaxOpenConnections)
    98→
    99→		dbHandle.SetMaxOpenConns(opts.MaxOpenConnections)
   100→		dbHandle.SetMaxIdleConns(opts.MaxIdleConnections)
   101→		dbHandle.SetConnMaxLifetime(opts.ConnectionMaxLifetime)
   102→		dbHandle.SetConnMaxIdleTime(opts.ConnectionMaxIdleTime)
   103→
   104→		dbConnInstance = &DBConnection{DB: dbHandle}
   105→		err = checkDBVersion(dbHandle.DB)
   106→		if err != nil {
   107→			cclog.Abortf("DB Connection: Failed DB version check.\nError: %s\n", err.Error())
   108→		}
   109→	})
   110→}
   111→
   112→func GetConnection() *DBConnection {
   113→	if dbConnInstance == nil {
   114→		cclog.Fatalf("Database connection not initialized!")
   115→	}
   116→
   117→	return dbConnInstance
   118→}
   119→
   120→// ResetConnection closes the current database connection and resets the connection state.
   121→// This function is intended for testing purposes only to allow test isolation.
   122→func ResetConnection() error {
   123→	if dbConnInstance != nil && dbConnInstance.DB != nil {
   124→		if err := dbConnInstance.DB.Close(); err != nil {
   125→			return fmt.Errorf("failed to close database connection: %w", err)
   126→		}
   127→	}
   128→
   129→	dbConnInstance = nil
   130→	dbConnOnce = sync.Once{}
   131→	jobRepoInstance = nil
   132→	jobRepoOnce = sync.Once{}
   133→	nodeRepoInstance = nil
   134→	nodeRepoOnce = sync.Once{}
   135→	userRepoInstance = nil
   136→	userRepoOnce = sync.Once{}
   137→	userCfgRepoInstance = nil
   138→	userCfgRepoOnce = sync.Once{}
   139→
   140→	return nil
   141→}
   142→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package repository

import (
	"database/sql"
	"fmt"
	"net/url"
	"sync"
	"time"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/jmoiron/sqlx"
	"github.com/mattn/go-sqlite3"
	"github.com/qustavo/sqlhooks/v2"
)

var (
	dbConnOnce     sync.Once
	dbConnInstance *DBConnection
)

type DBConnection struct {
	DB     *sqlx.DB
	Driver string
}

type DatabaseOptions struct {
	URL                   string
	MaxOpenConnections    int
	MaxIdleConnections    int
	ConnectionMaxLifetime time.Duration
	ConnectionMaxIdleTime time.Duration
}

func setupSqlite(db *sql.DB, cfg *RepositoryConfig) error {
	pragmas := []string{
		"temp_store = memory",
		fmt.Sprintf("soft_heap_limit = %d", int64(cfg.DbSoftHeapLimitMB)*1024*1024),
	}

	for _, pragma := range pragmas {
		_, err := db.Exec("PRAGMA " + pragma)
		if err != nil {
			return err
		}
	}

	return nil
}

func Connect(db string) {
	var err error
	var dbHandle *sqlx.DB

	dbConnOnce.Do(func() {
		opts := DatabaseOptions{
			URL:                   db,
			MaxOpenConnections:    repoConfig.MaxOpenConnections,
			MaxIdleConnections:    repoConfig.MaxIdleConnections,
			ConnectionMaxLifetime: repoConfig.ConnectionMaxLifetime,
			ConnectionMaxIdleTime: repoConfig.ConnectionMaxIdleTime,
		}

		// TODO: Have separate DB handles for Writes and Reads
		// Optimize SQLite connection: https://kerkour.com/sqlite-for-servers
		connectionURLParams := make(url.Values)
		connectionURLParams.Add("_txlock", "immediate")
		connectionURLParams.Add("_journal_mode", "WAL")
		connectionURLParams.Add("_busy_timeout", "5000")
		connectionURLParams.Add("_synchronous", "NORMAL")
		cacheSizeKiB := repoConfig.DbCacheSizeMB * 1024 // Convert MB to KiB
		connectionURLParams.Add("_cache_size", fmt.Sprintf("-%d", cacheSizeKiB))
		connectionURLParams.Add("_foreign_keys", "true")
		opts.URL = fmt.Sprintf("file:%s?%s", opts.URL, connectionURLParams.Encode())

		if cclog.Loglevel() == "debug" {
			sql.Register("sqlite3WithHooks", sqlhooks.Wrap(&sqlite3.SQLiteDriver{}, &Hooks{}))
			dbHandle, err = sqlx.Open("sqlite3WithHooks", opts.URL)
		} else {
			dbHandle, err = sqlx.Open("sqlite3", opts.URL)
		}

		if err != nil {
			cclog.Abortf("DB Connection: Could not connect to SQLite database with sqlx.Open().\nError: %s\n", err.Error())
		}

		err = setupSqlite(dbHandle.DB, repoConfig)
		if err != nil {
			cclog.Abortf("Failed sqlite db setup.\nError: %s\n", err.Error())
		}

		cclog.Infof("SQLite config: cache_size=%dMB/conn, soft_heap_limit=%dMB, max_conns=%d",
			repoConfig.DbCacheSizeMB, repoConfig.DbSoftHeapLimitMB, repoConfig.MaxOpenConnections)

		dbHandle.SetMaxOpenConns(opts.MaxOpenConnections)
		dbHandle.SetMaxIdleConns(opts.MaxIdleConnections)
		dbHandle.SetConnMaxLifetime(opts.ConnectionMaxLifetime)
		dbHandle.SetConnMaxIdleTime(opts.ConnectionMaxIdleTime)

		dbConnInstance = &DBConnection{DB: dbHandle}
		err = checkDBVersion(dbHandle.DB)
		if err != nil {
			cclog.Abortf("DB Connection: Failed DB version check.\nError: %s\n", err.Error())
		}
	})
}

func GetConnection() *DBConnection {
	if dbConnInstance == nil {
		cclog.Fatalf("Database connection not initialized!")
	}

	return dbConnInstance
}

// ResetConnection closes the current database connection and resets the connection state.
// This function is intended for testing purposes only to allow test isolation.
func ResetConnection() error {
	if dbConnInstance != nil && dbConnInstance.DB != nil {
		if err := dbConnInstance.DB.Close(); err != nil {
			return fmt.Errorf("failed to close database connection: %w", err)
		}
	}

	dbConnInstance = nil
	dbConnOnce = sync.Once{}
	jobRepoInstance = nil
	jobRepoOnce = sync.Once{}
	nodeRepoInstance = nil
	nodeRepoOnce = sync.Once{}
	userRepoInstance = nil
	userRepoOnce = sync.Once{}
	userCfgRepoInstance = nil
	userCfgRepoOnce = sync.Once{}

	return nil
}


     1→// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
     2→// All rights reserved. This file is part of cc-backend.
     3→// Use of this source code is governed by a MIT-style
     4→// license that can be found in the LICENSE file.
     5→
     6→package repository
     7→
     8→import (
     9→	"database/sql"
    10→	"embed"
    11→	"fmt"
    12→
    13→	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
    14→	"github.com/golang-migrate/migrate/v4"
    15→	"github.com/golang-migrate/migrate/v4/database/sqlite3"
    16→	"github.com/golang-migrate/migrate/v4/source/iofs"
    17→)
    18→
    19→// Version is the current database schema version required by this version of cc-backend.
    20→// When the database schema changes, this version is incremented and a new migration file
    21→// is added to internal/repository/migrations/sqlite3/.
    22→//
    23→// Version history:
    24→//   - Version 12: Add covering index for stats queries (cluster, start_time, hpc_user, ...)
    25→//   - Version 11: Optimize job table indexes (reduce from ~78 to 48)
    26→//   - Version 10: Node table
    27→//
    28→// Migration files are embedded at build time from the migrations directory.
    29→const Version uint = 12
    30→
    31→//go:embed migrations/*
    32→var migrationFiles embed.FS
    33→
    34→// checkDBVersion verifies that the database schema version matches the expected version.
    35→// This is called automatically during Connect() to ensure schema compatibility.
    36→//
    37→// Returns an error if:
    38→//   - Database version is older than expected (needs migration)
    39→//   - Database version is newer than expected (needs app upgrade)
    40→//   - Database is in a dirty state (failed migration)
    41→//
    42→// A "dirty" database indicates a migration was started but not completed successfully.
    43→// This requires manual intervention to fix the database and force the version.
    44→func checkDBVersion(db *sql.DB) error {
    45→	driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
    46→	if err != nil {
    47→		return err
    48→	}
    49→	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
    50→	if err != nil {
    51→		return err
    52→	}
    53→
    54→	m, err := migrate.NewWithInstance("iofs", d, "sqlite3", driver)
    55→	if err != nil {
    56→		return err
    57→	}
    58→
    59→	v, dirty, err := m.Version()
    60→	if err != nil {
    61→		if err == migrate.ErrNilVersion {
    62→			cclog.Warn("Legacy database without version or missing database file!")
    63→		} else {
    64→			return err
    65→		}
    66→	}
    67→
    68→	if v < Version {
    69→		return fmt.Errorf("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
    70→	} else if v > Version {
    71→		return fmt.Errorf("unsupported database version %d, need %d.\nPlease refer to documentation how to downgrade db with external migrate tool", v, Version)
    72→	}
    73→
    74→	if dirty {
    75→		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
    76→	}
    77→
    78→	return nil
    79→}
    80→
    81→// getMigrateInstance creates a new migration instance for the given database file.
    82→// This is used internally by MigrateDB, RevertDB, and ForceDB.
    83→func getMigrateInstance(db string) (m *migrate.Migrate, err error) {
    84→	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
    85→	if err != nil {
    86→		return nil, err
    87→	}
    88→
    89→	m, err = migrate.NewWithSourceInstance("iofs", d, fmt.Sprintf("sqlite3://%s?_foreign_keys=on", db))
    90→	if err != nil {
    91→		return nil, err
    92→	}
    93→
    94→	return m, nil
    95→}
    96→
    97→// MigrateDB applies all pending database migrations to bring the schema up to date.
    98→// This should be run with the -migrate-db flag before starting the application
    99→// after upgrading to a new version that requires schema changes.
   100→//
   101→// Process:
   102→//  1. Checks current database version
   103→//  2. Applies all migrations from current version to target Version
   104→//  3. Updates schema_migrations table to track applied migrations
   105→//
   106→// Important:
   107→//   - Always backup your database before running migrations
   108→//   - Migrations are irreversible without manual intervention
   109→//   - If a migration fails, the database is marked "dirty" and requires manual fix
   110→//
   111→// Usage:
   112→//
   113→//	cc-backend -migrate-db
   114→func MigrateDB(db string) error {
   115→	m, err := getMigrateInstance(db)
   116→	if err != nil {
   117→		return err
   118→	}
   119→
   120→	v, dirty, err := m.Version()
   121→	if err != nil {
   122→		if err == migrate.ErrNilVersion {
   123→			cclog.Info("Legacy database without version or missing database file!")
   124→		} else {
   125→			return err
   126→		}
   127→	}
   128→
   129→	if v < Version {
   130→		cclog.Infof("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
   131→	}
   132→
   133→	if dirty {
   134→		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
   135→	}
   136→
   137→	if err := m.Up(); err != nil {
   138→		if err == migrate.ErrNoChange {
   139→			cclog.Info("DB already up to date!")
   140→		} else {
   141→			return err
   142→		}
   143→	}
   144→
   145→	m.Close()
   146→	return nil
   147→}
   148→
   149→// RevertDB rolls back the database schema to the previous version (Version - 1).
   150→// This is primarily used for testing or emergency rollback scenarios.
   151→//
   152→// Warning:
   153→//   - This may cause data loss if newer schema added columns/tables
   154→//   - Always backup before reverting
   155→//   - Not all migrations are safely reversible
   156→//
   157→// Usage:
   158→//
   159→//	cc-backend -revert-db
   160→func RevertDB(db string) error {
   161→	m, err := getMigrateInstance(db)
   162→	if err != nil {
   163→		return err
   164→	}
   165→
   166→	if err := m.Migrate(Version - 1); err != nil {
   167→		if err == migrate.ErrNoChange {
   168→			cclog.Info("DB already up to date!")
   169→		} else {
   170→			return err
   171→		}
   172→	}
   173→
   174→	m.Close()
   175→	return nil
   176→}
   177→
   178→// ForceDB forces the database schema version to the current Version without running migrations.
   179→// This is only used to recover from failed migrations that left the database in a "dirty" state.
   180→//
   181→// When to use:
   182→//   - After manually fixing a failed migration
   183→//   - When you've manually applied schema changes and need to update the version marker
   184→//
   185→// Warning:
   186→//   - This does NOT apply any schema changes
   187→//   - Only use after manually verifying the schema is correct
   188→//   - Improper use can cause schema/version mismatch
   189→//
   190→// Usage:
   191→//
   192→//	cc-backend -force-db
   193→func ForceDB(db string) error {
   194→	m, err := getMigrateInstance(db)
   195→	if err != nil {
   196→		return err
   197→	}
   198→
   199→	if err := m.Force(int(Version)); err != nil {
   200→		return err
   201→	}
   202→
   203→	m.Close()
   204→	return nil
   205→}
   206→

// Copyright (C) NHR@FAU, University Erlangen-Nuremberg.
// All rights reserved. This file is part of cc-backend.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package repository

import (
	"database/sql"
	"embed"
	"fmt"

	cclog "github.com/ClusterCockpit/cc-lib/v2/ccLogger"
	"github.com/golang-migrate/migrate/v4"
	"github.com/golang-migrate/migrate/v4/database/sqlite3"
	"github.com/golang-migrate/migrate/v4/source/iofs"
)

// Version is the current database schema version required by this version of cc-backend.
// When the database schema changes, this version is incremented and a new migration file
// is added to internal/repository/migrations/sqlite3/.
//
// Version history:
//   - Version 12: Add covering index for stats queries (cluster, start_time, hpc_user, ...)
//   - Version 11: Optimize job table indexes (reduce from ~78 to 48)
//   - Version 10: Node table
//
// Migration files are embedded at build time from the migrations directory.
const Version uint = 12

//go:embed migrations/*
var migrationFiles embed.FS

// checkDBVersion verifies that the database schema version matches the expected version.
// This is called automatically during Connect() to ensure schema compatibility.
//
// Returns an error if:
//   - Database version is older than expected (needs migration)
//   - Database version is newer than expected (needs app upgrade)
//   - Database is in a dirty state (failed migration)
//
// A "dirty" database indicates a migration was started but not completed successfully.
// This requires manual intervention to fix the database and force the version.
func checkDBVersion(db *sql.DB) error {
	driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
	if err != nil {
		return err
	}
	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
	if err != nil {
		return err
	}

	m, err := migrate.NewWithInstance("iofs", d, "sqlite3", driver)
	if err != nil {
		return err
	}

	v, dirty, err := m.Version()
	if err != nil {
		if err == migrate.ErrNilVersion {
			cclog.Warn("Legacy database without version or missing database file!")
		} else {
			return err
		}
	}

	if v < Version {
		return fmt.Errorf("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
	} else if v > Version {
		return fmt.Errorf("unsupported database version %d, need %d.\nPlease refer to documentation how to downgrade db with external migrate tool", v, Version)
	}

	if dirty {
		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
	}

	return nil
}

// getMigrateInstance creates a new migration instance for the given database file.
// This is used internally by MigrateDB, RevertDB, and ForceDB.
func getMigrateInstance(db string) (m *migrate.Migrate, err error) {
	d, err := iofs.New(migrationFiles, "migrations/sqlite3")
	if err != nil {
		return nil, err
	}

	m, err = migrate.NewWithSourceInstance("iofs", d, fmt.Sprintf("sqlite3://%s?_foreign_keys=on", db))
	if err != nil {
		return nil, err
	}

	return m, nil
}

// MigrateDB applies all pending database migrations to bring the schema up to date.
// This should be run with the -migrate-db flag before starting the application
// after upgrading to a new version that requires schema changes.
//
// Process:
//  1. Checks current database version
//  2. Applies all migrations from current version to target Version
//  3. Updates schema_migrations table to track applied migrations
//
// Important:
//   - Always backup your database before running migrations
//   - Migrations are irreversible without manual intervention
//   - If a migration fails, the database is marked "dirty" and requires manual fix
//
// Usage:
//
//	cc-backend -migrate-db
func MigrateDB(db string) error {
	m, err := getMigrateInstance(db)
	if err != nil {
		return err
	}

	v, dirty, err := m.Version()
	if err != nil {
		if err == migrate.ErrNilVersion {
			cclog.Info("Legacy database without version or missing database file!")
		} else {
			return err
		}
	}

	if v < Version {
		cclog.Infof("unsupported database version %d, need %d.\nPlease backup your database file and run cc-backend -migrate-db", v, Version)
	}

	if dirty {
		return fmt.Errorf("last migration to version %d has failed, please fix the db manually and force version with -force-db flag", Version)
	}

	if err := m.Up(); err != nil {
		if err == migrate.ErrNoChange {
			cclog.Info("DB already up to date!")
		} else {
			return err
		}
	}

	m.Close()
	return nil
}

// RevertDB rolls back the database schema to the previous version (Version - 1).
// This is primarily used for testing or emergency rollback scenarios.
//
// Warning:
//   - This may cause data loss if newer schema added columns/tables
//   - Always backup before reverting
//   - Not all migrations are safely reversible
//
// Usage:
//
//	cc-backend -revert-db
func RevertDB(db string) error {
	m, err := getMigrateInstance(db)
	if err != nil {
		return err
	}

	if err := m.Migrate(Version - 1); err != nil {
		if err == migrate.ErrNoChange {
			cclog.Info("DB already up to date!")
		} else {
			return err
		}
	}

	m.Close()
	return nil
}

// ForceDB forces the database schema version to the current Version without running migrations.
// This is only used to recover from failed migrations that left the database in a "dirty" state.
//
// When to use:
//   - After manually fixing a failed migration
//   - When you've manually applied schema changes and need to update the version marker
//
// Warning:
//   - This does NOT apply any schema changes
//   - Only use after manually verifying the schema is correct
//   - Improper use can cause schema/version mismatch
//
// Usage:
//
//	cc-backend -force-db
func ForceDB(db string) error {
	m, err := getMigrateInstance(db)
	if err != nil {
		return err
	}

	if err := m.Force(int(Version)); err != nil {
		return err
	}

	m.Close()
	return nil
}


Now I have a clear picture. Let me design the plan.


Agent

Design stats query optimization

Prompt:
I need to optimize a slow SQLite stats query in a Go backend (ClusterCockpit cc-backend). Here's the situation:

## Problem
The endpoint `/monitoring/users/?cluster=woody&startTime=last30d` calls `buildStatsQuery()` which generates a SQL query like:

```sql
SELECT job.hpc_user,
  COUNT(job.id) as totalJobs,
  0 AS totalUsers,
  CAST(ROUND(SUM(CASE WHEN job.job_state = "running" THEN <now> - job.start_time ELSE job.duration END) / 3600) as int) as totalWalltime,
  ...similar aggregations for totalCoreHours, totalAccHours...
  COUNT(CASE WHEN job.duration < 300 THEN 1 END) as shortJobs
FROM job
WHERE job.cluster = ? AND job.start_time >= ?
GROUP BY job.hpc_user
```

The query "does not return" - essentially hangs on a production database.

## Current Index (Migration 12)
```sql
CREATE INDEX jobs_cluster_starttime_user_stats
  ON job (cluster, start_time, hpc_user, duration, job_state, num_nodes, num_hwthreads, num_acc);
```

## Other relevant indexes
- `jobs_cluster_starttime ON (cluster, start_time)` - from migration 08
- `jobs_cluster_user ON (cluster, hpc_user)` - from migration 08

## Key Observations

1. **Index column order problem**: The covering index has `(cluster, start_time, hpc_user, ...)`. The WHERE clause uses `cluster = ?` (equality) and `start_time >= ?` (range). The GROUP BY is on `hpc_user`. Since `start_time` is a range condition BEFORE `hpc_user`, the index can't provide ordered GROUP BY output. SQLite must sort all matching rows into a temp B-Tree before aggregating. For hundreds of thousands of jobs in 30 days, this sort is extremely expensive.

2. **Better index order**: `(cluster, hpc_user, start_time, ...)` would allow:
   - Equality on cluster (prefix)
   - Index-ordered GROUP BY on hpc_user (next column)
   - Within each (cluster, hpc_user) group, start_time is sorted so the >= filter uses efficient skip-scan
   - All aggregation columns are in the index (covering)

3. **COUNT(job.id)**: Uses the rowid which is implicitly in the index, but `COUNT(*)` is clearer and avoids any potential table lookups.

4. **Double quotes in CASE**: `job.job_state = "running"` uses double quotes (SQL identifiers). Should be single quotes for string literals, though SQLite handles this gracefully.

## Files to modify
- `internal/repository/migrations/sqlite3/12_stats-covering-index.up.sql` - reorder index columns
- `internal/repository/stats.go` - change COUNT(job.id) to COUNT(*), fix double quotes

Please provide a detailed plan for these optimizations, including:
1. The exact index change (with reasoning)
2. Code changes in stats.go
3. Whether we should drop the redundant `jobs_cluster_starttime` index (since we have a covering index that starts with cluster)
4. Testing/verification approach


I need to optimize a slow SQLite stats query in a Go backend (ClusterCockpit cc-backend). Here's the situation:

## Problem
The endpoint `/monitoring/users/?cluster=woody&startTime=last30d` calls `buildStatsQuery()` which generates a SQL query like:

```sql
SELECT job.hpc_user,
  COUNT(job.id) as totalJobs,
  0 AS totalUsers,
  CAST(ROUND(SUM(CASE WHEN job.job_state = "running" THEN <now> - job.start_time ELSE job.duration END) / 3600) as int) as totalWalltime,
  ...similar aggregations for totalCoreHours, totalAccHours...
  COUNT(CASE WHEN job.duration < 300 THEN 1 END) as shortJobs
FROM job
WHERE job.cluster = ? AND job.start_time >= ?
GROUP BY job.hpc_user
```

The query "does not return" - essentially hangs on a production database.

## Current Index (Migration 12)
```sql
CREATE INDEX jobs_cluster_starttime_user_stats
  ON job (cluster, start_time, hpc_user, duration, job_state, num_nodes, num_hwthreads, num_acc);
```

## Other relevant indexes
- `jobs_cluster_starttime ON (cluster, start_time)` - from migration 08
- `jobs_cluster_user ON (cluster, hpc_user)` - from migration 08

## Key Observations

1. **Index column order problem**: The covering index has `(cluster, start_time, hpc_user, ...)`. The WHERE clause uses `cluster = ?` (equality) and `start_time >= ?` (range). The GROUP BY is on `hpc_user`. Since `start_time` is a range condition BEFORE `hpc_user`, the index can't provide ordered GROUP BY output. SQLite must sort all matching rows into a temp B-Tree before aggregating. For hundreds of thousands of jobs in 30 days, this sort is extremely expensive.

2. **Better index order**: `(cluster, hpc_user, start_time, ...)` would allow:
   - Equality on cluster (prefix)
   - Index-ordered GROUP BY on hpc_user (next column)
   - Within each (cluster, hpc_user) group, start_time is sorted so the >= filter uses efficient skip-scan
   - All aggregation columns are in the index (covering)

3. **COUNT(job.id)**: Uses the rowid which is implicitly in the index, but `COUNT(*)` is clearer and avoids any potential table lookups.

4. **Double quotes in CASE**: `job.job_state = "running"` uses double quotes (SQL identifiers). Should be single quotes for string literals, though SQLite handles this gracefully.

## Files to modify
- `internal/repository/migrations/sqlite3/12_stats-covering-index.up.sql` - reorder index columns
- `internal/repository/stats.go` - change COUNT(job.id) to COUNT(*), fix double quotes

Please provide a detailed plan for these optimizations, including:
1. The exact index change (with reasoning)
2. Code changes in stats.go
3. Whether we should drop the redundant `jobs_cluster_starttime` index (since we have a covering index that starts with cluster)
4. Testing/verification approach

Prompt:
I need to optimize a slow SQLite stats query in a Go backend (ClusterCockpit cc-backend). Here's the situation:

## Problem
The endpoint `/monitoring/users/?cluster=woody&startTime=last30d` calls `buildStatsQuery()` which generates a SQL query like:

```sql
SELECT job.hpc_user,
  COUNT(job.id) as totalJobs,
  0 AS totalUsers,
  CAST(ROUND(SUM(CASE WHEN job.job_state = "running" THEN <now> - job.start_time ELSE job.duration END) / 3600) as int) as totalWalltime,
  ...similar aggregations for totalCoreHours, totalAccHours...
  COUNT(CASE WHEN job.duration < 300 THEN 1 END) as shortJobs
FROM job
WHERE job.cluster = ? AND job.start_time >= ?
GROUP BY job.hpc_user
```

The query "does not return" - essentially hangs on a production database.

## Current Index (Migration 12)
```sql
CREATE INDEX jobs_cluster_starttime_user_stats
  ON job (cluster, start_time, hpc_user, duration, job_state, num_nodes, num_hwthreads, num_acc);
```

## Other relevant indexes
- `jobs_cluster_starttime ON (cluster, start_time)` - from migration 08
- `jobs_cluster_user ON (cluster, hpc_user)` - from migration 08

## Key Observations

1. **Index column order problem**: The covering index has `(cluster, start_time, hpc_user, ...)`. The WHERE clause uses `cluster = ?` (equality) and `start_time >= ?` (range). The GROUP BY is on `hpc_user`. Since `start_time` is a range condition BEFORE `hpc_user`, the index can't provide ordered GROUP BY output. SQLite must sort all matching rows into a temp B-Tree before aggregating. For hundreds of thousands of jobs in 30 days, this sort is extremely expensive.

2. **Better index order**: `(cluster, hpc_user, start_time, ...)` would allow:
   - Equality on cluster (prefix)
   - Index-ordered GROUP BY on hpc_user (next column)
   - Within each (cluster, hpc_user) group, start_time is sorted so the >= filter uses efficient skip-scan
   - All aggregation columns are in the index (covering)

3. **COUNT(job.id)**: Uses the rowid which is implicitly in the index, but `COUNT(*)` is clearer and avoids any potential table lookups.

4. **Double quotes in CASE**: `job.job_state = "running"` uses double quotes (SQL identifiers). Should be single quotes for string literals, though SQLite handles this gracefully.

## Files to modify
- `internal/repository/migrations/sqlite3/12_stats-covering-index.up.sql` - reorder index columns
- `internal/repository/stats.go` - change COUNT(job.id) to COUNT(*), fix double quotes

Please provide a detailed plan for these optimizations, including:
1. The exact index change (with reasoning)
2. Code changes in stats.go
3. Whether we should drop the redundant `jobs_cluster_starttime` index (since we have a covering index that starts with cluster)
4. Testing/verification approach


     1→-- Migration 12: Add covering index for grouped stats queries
     2→-- Covers the common pattern: WHERE cluster=X AND start_time >= Y GROUP BY hpc_user
     3→-- Includes aggregated columns to avoid main table lookups entirely.
     4→
     5→CREATE INDEX IF NOT EXISTS jobs_cluster_starttime_user_stats
     6→  ON job (cluster, start_time, hpc_user, duration, job_state, num_nodes, num_hwthreads, num_acc);
     7→
     8→PRAGMA optimize;
     9→


     1→DROP INDEX IF EXISTS jobs_cluster_starttime_user_stats;
     2→