mirror of
https://github.com/MeshEnvy/mesh-forge.git
synced 2026-08-07 09:22:56 +02:00
refactor: standardize code formatting across multiple files, improve linting commands, and enhance build management functionality
This commit is contained in:
+10
-4
@@ -6,7 +6,13 @@
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["./**", "convex/**", "src/**", "!vendor", "!convex/_generated"]
|
||||
"includes": [
|
||||
"src/**",
|
||||
"convex/**",
|
||||
"*.json",
|
||||
"!vendor",
|
||||
"!convex/_generated"
|
||||
]
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
@@ -28,12 +34,12 @@
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"quoteStyle": "single",
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"quoteProperties": "asNeeded",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all"
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "es5"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
|
||||
+69
-66
@@ -1,76 +1,79 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import { action } from "./_generated/server";
|
||||
import { v } from 'convex/values'
|
||||
import { internal } from './_generated/api'
|
||||
import { action } from './_generated/server'
|
||||
|
||||
export const dispatchGithubBuild = action({
|
||||
args: {
|
||||
buildId: v.id("builds"),
|
||||
target: v.string(),
|
||||
flags: v.string(),
|
||||
version: v.string(),
|
||||
buildHash: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const githubToken = process.env.GITHUB_TOKEN;
|
||||
if (!githubToken) {
|
||||
throw new Error("GITHUB_TOKEN is not set");
|
||||
}
|
||||
args: {
|
||||
buildId: v.id('builds'),
|
||||
target: v.string(),
|
||||
flags: v.string(),
|
||||
version: v.string(),
|
||||
buildHash: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const githubToken = process.env.GITHUB_TOKEN
|
||||
if (!githubToken) {
|
||||
throw new Error('GITHUB_TOKEN is not set')
|
||||
}
|
||||
|
||||
const convexUrl = process.env.CONVEX_SITE_URL;
|
||||
if (!convexUrl) {
|
||||
console.error("CONVEX_SITE_URL is not set");
|
||||
// Proceeding anyway might fail if workflow requires it
|
||||
}
|
||||
const convexUrl = process.env.CONVEX_SITE_URL
|
||||
if (!convexUrl) {
|
||||
console.error('CONVEX_SITE_URL is not set')
|
||||
// Proceeding anyway might fail if workflow requires it
|
||||
}
|
||||
|
||||
console.log("dispatchGithubBuild args:", JSON.stringify(args, null, 2));
|
||||
console.log('dispatchGithubBuild args:', JSON.stringify(args, null, 2))
|
||||
|
||||
if (!args.buildHash) {
|
||||
throw new Error("args.buildHash is missing or empty");
|
||||
}
|
||||
if (!args.buildHash) {
|
||||
throw new Error('args.buildHash is missing or empty')
|
||||
}
|
||||
|
||||
const payload = {
|
||||
ref: "main", // or make this configurable
|
||||
inputs: {
|
||||
target: args.target,
|
||||
flags: args.flags,
|
||||
version: args.version,
|
||||
build_id: args.buildId,
|
||||
build_hash: args.buildHash,
|
||||
convex_url: convexUrl || "https://example.com", // Fallback to avoid missing input error if that's the cause
|
||||
},
|
||||
};
|
||||
const payload = {
|
||||
ref: 'main', // or make this configurable
|
||||
inputs: {
|
||||
target: args.target,
|
||||
flags: args.flags,
|
||||
version: args.version,
|
||||
build_id: args.buildId,
|
||||
build_hash: args.buildHash,
|
||||
convex_url: convexUrl || 'https://example.com', // Fallback to avoid missing input error if that's the cause
|
||||
},
|
||||
}
|
||||
|
||||
console.log("Dispatching GitHub build with payload:", JSON.stringify(payload, null, 2));
|
||||
console.log(
|
||||
'Dispatching GitHub build with payload:',
|
||||
JSON.stringify(payload, null, 2)
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://api.github.com/repos/MeshEnvy/configurable-web-flasher/actions/workflows/custom_build.yml/dispatches",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
Accept: "application/vnd.github.v3+json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
try {
|
||||
const response = await fetch(
|
||||
'https://api.github.com/repos/MeshEnvy/configurable-web-flasher/actions/workflows/custom_build.yml/dispatches',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`GitHub API failed: ${response.status} ${errorText}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`GitHub API failed: ${response.status} ${errorText}`)
|
||||
}
|
||||
|
||||
// Note: GitHub dispatch API doesn't return the run ID immediately.
|
||||
// We rely on the webhook to link the run back to our build record.
|
||||
// Alternatively, we could poll for the most recent run, but that's race-condition prone.
|
||||
} catch (error) {
|
||||
await ctx.runMutation(internal.builds.logBuildError, {
|
||||
buildId: args.buildId,
|
||||
error: String(error),
|
||||
});
|
||||
// Re-throw so it shows up in Convex logs too
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
});
|
||||
// Note: GitHub dispatch API doesn't return the run ID immediately.
|
||||
// We rely on the webhook to link the run back to our build record.
|
||||
// Alternatively, we could poll for the most recent run, but that's race-condition prone.
|
||||
} catch (error) {
|
||||
await ctx.runMutation(internal.builds.logBuildError, {
|
||||
buildId: args.buildId,
|
||||
error: String(error),
|
||||
})
|
||||
// Re-throw so it shows up in Convex logs too
|
||||
throw error
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: "convex",
|
||||
applicationID: 'convex',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
import Google from "@auth/core/providers/google";
|
||||
import { convexAuth } from "@convex-dev/auth/server";
|
||||
import Google from '@auth/core/providers/google'
|
||||
import { convexAuth } from '@convex-dev/auth/server'
|
||||
|
||||
export const { auth, signIn, signOut, store } = convexAuth({
|
||||
providers: [Google],
|
||||
});
|
||||
providers: [Google],
|
||||
})
|
||||
|
||||
+115
-117
@@ -1,14 +1,14 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { v } from "convex/values";
|
||||
import { api } from "./_generated/api";
|
||||
import { internalMutation, mutation, query } from "./_generated/server";
|
||||
import modulesData from "./modules.json";
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { api } from './_generated/api'
|
||||
import { internalMutation, mutation, query } from './_generated/server'
|
||||
import modulesData from './modules.json'
|
||||
|
||||
type BuildUpdateData = {
|
||||
status: string;
|
||||
completedAt?: number;
|
||||
artifactUrl?: string;
|
||||
};
|
||||
status: string
|
||||
completedAt?: number
|
||||
artifactUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes a stable SHA-256 hash from version, target, and flags.
|
||||
@@ -17,25 +17,23 @@ type BuildUpdateData = {
|
||||
async function computeBuildHash(
|
||||
version: string,
|
||||
target: string,
|
||||
flags: string,
|
||||
flags: string
|
||||
): Promise<string> {
|
||||
// Input is now the exact parameters used for the build
|
||||
const input = JSON.stringify({
|
||||
version,
|
||||
target,
|
||||
flags,
|
||||
});
|
||||
})
|
||||
|
||||
// Use Web Crypto API for SHA-256 hashing
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(input)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
||||
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
|
||||
return hashHex;
|
||||
return hashHex
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,46 +42,46 @@ async function computeBuildHash(
|
||||
* Or custom domain if R2_PUBLIC_URL is set.
|
||||
*/
|
||||
function getR2ArtifactUrl(buildHash: string): string {
|
||||
const r2PublicUrl = process.env.R2_PUBLIC_URL;
|
||||
const r2PublicUrl = process.env.R2_PUBLIC_URL
|
||||
if (r2PublicUrl) {
|
||||
// Custom domain configured
|
||||
return `${r2PublicUrl}/${buildHash}.uf2`;
|
||||
return `${r2PublicUrl}/${buildHash}.uf2`
|
||||
}
|
||||
// Default R2 public URL pattern (requires public bucket)
|
||||
const bucketName = process.env.R2_BUCKET_NAME || "firmware-builds";
|
||||
const accountId = process.env.R2_ACCOUNT_ID || "";
|
||||
const bucketName = process.env.R2_BUCKET_NAME || 'firmware-builds'
|
||||
const accountId = process.env.R2_ACCOUNT_ID || ''
|
||||
if (accountId) {
|
||||
return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${buildHash}.uf2`;
|
||||
return `https://${bucketName}.${accountId}.r2.cloudflarestorage.com/${buildHash}.uf2`
|
||||
}
|
||||
// Fallback: assume custom domain or public bucket URL
|
||||
return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2`;
|
||||
return `https://${bucketName}.r2.cloudflarestorage.com/${buildHash}.uf2`
|
||||
}
|
||||
|
||||
export const triggerBuild = mutation({
|
||||
args: {
|
||||
profileId: v.id("profiles"),
|
||||
profileId: v.id('profiles'),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const profile = await ctx.db.get(args.profileId);
|
||||
const profile = await ctx.db.get(args.profileId)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// Convert config object to flags string
|
||||
const flags: string[] = [];
|
||||
const flags: string[] = []
|
||||
|
||||
// Handle Modules (Inverted Logic: Default Excluded)
|
||||
for (const module of modulesData.modules) {
|
||||
// If config[id] is NOT false (explicitly included), we exclude it.
|
||||
if (profile.config[module.id] !== false) {
|
||||
flags.push(`-D${module.id}=1`);
|
||||
flags.push(`-D${module.id}=1`)
|
||||
}
|
||||
}
|
||||
|
||||
const flagsString = flags.join(" ");
|
||||
const flagsString = flags.join(' ')
|
||||
|
||||
// Create build records for each target
|
||||
for (const target of profile.targets) {
|
||||
@@ -91,44 +89,44 @@ export const triggerBuild = mutation({
|
||||
const buildHash = await computeBuildHash(
|
||||
profile.version,
|
||||
target,
|
||||
flagsString,
|
||||
);
|
||||
flagsString
|
||||
)
|
||||
|
||||
console.log(
|
||||
`Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})`,
|
||||
);
|
||||
`Computed build hash for ${target}: ${buildHash} (Flags: ${flagsString})`
|
||||
)
|
||||
|
||||
// Check cache for existing build
|
||||
const cached = await ctx.db
|
||||
.query("buildCache")
|
||||
.withIndex("by_hash_target", (q) =>
|
||||
q.eq("buildHash", buildHash).eq("target", target),
|
||||
.query('buildCache')
|
||||
.withIndex('by_hash_target', (q) =>
|
||||
q.eq('buildHash', buildHash).eq('target', target)
|
||||
)
|
||||
.first();
|
||||
.first()
|
||||
|
||||
if (cached) {
|
||||
// Use cached artifact, skip GitHub workflow
|
||||
const artifactUrl = getR2ArtifactUrl(buildHash);
|
||||
await ctx.db.insert("builds", {
|
||||
const artifactUrl = getR2ArtifactUrl(buildHash)
|
||||
await ctx.db.insert('builds', {
|
||||
profileId: profile._id,
|
||||
target: target,
|
||||
githubRunId: 0,
|
||||
status: "success",
|
||||
status: 'success',
|
||||
artifactUrl: artifactUrl,
|
||||
startedAt: Date.now(),
|
||||
completedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
});
|
||||
})
|
||||
} else {
|
||||
// Not cached, proceed with normal build flow
|
||||
const buildId = await ctx.db.insert("builds", {
|
||||
const buildId = await ctx.db.insert('builds', {
|
||||
profileId: profile._id,
|
||||
target: target,
|
||||
githubRunId: 0,
|
||||
status: "queued",
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
buildHash: buildHash,
|
||||
});
|
||||
})
|
||||
|
||||
// Schedule the action to dispatch GitHub workflow
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
@@ -137,107 +135,107 @@ export const triggerBuild = mutation({
|
||||
flags: flagsString,
|
||||
version: profile.version,
|
||||
buildHash: buildHash,
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const listByProfile = query({
|
||||
args: { profileId: v.id("profiles") },
|
||||
args: { profileId: v.id('profiles') },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("builds")
|
||||
.withIndex("by_profile", (q) => q.eq("profileId", args.profileId))
|
||||
.order("desc")
|
||||
.take(10);
|
||||
.query('builds')
|
||||
.withIndex('by_profile', (q) => q.eq('profileId', args.profileId))
|
||||
.order('desc')
|
||||
.take(10)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const get = query({
|
||||
args: { buildId: v.id("builds") },
|
||||
args: { buildId: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return null;
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) return null
|
||||
|
||||
const build = await ctx.db.get(args.buildId);
|
||||
if (!build) return null;
|
||||
const build = await ctx.db.get(args.buildId)
|
||||
if (!build) return null
|
||||
|
||||
const profile = await ctx.db.get(build.profileId);
|
||||
if (!profile || profile.userId !== userId) return null;
|
||||
const profile = await ctx.db.get(build.profileId)
|
||||
if (!profile || profile.userId !== userId) return null
|
||||
|
||||
return build;
|
||||
return build
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Internal query to get build without auth checks (for webhooks)
|
||||
export const getInternal = internalMutation({
|
||||
args: { buildId: v.id("builds") },
|
||||
args: { buildId: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.buildId);
|
||||
return await ctx.db.get(args.buildId)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const deleteBuild = mutation({
|
||||
args: { buildId: v.id("builds") },
|
||||
args: { buildId: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const build = await ctx.db.get(args.buildId);
|
||||
if (!build) throw new Error("Build not found");
|
||||
const build = await ctx.db.get(args.buildId)
|
||||
if (!build) throw new Error('Build not found')
|
||||
|
||||
const profile = await ctx.db.get(build.profileId);
|
||||
const profile = await ctx.db.get(build.profileId)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
await ctx.db.delete(args.buildId);
|
||||
await ctx.db.delete(args.buildId)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
export const retryBuild = mutation({
|
||||
args: { buildId: v.id("builds") },
|
||||
args: { buildId: v.id('builds') },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const build = await ctx.db.get(args.buildId);
|
||||
if (!build) throw new Error("Build not found");
|
||||
const build = await ctx.db.get(args.buildId)
|
||||
if (!build) throw new Error('Build not found')
|
||||
|
||||
const profile = await ctx.db.get(build.profileId);
|
||||
const profile = await ctx.db.get(build.profileId)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
// Reset build status
|
||||
await ctx.db.patch(args.buildId, {
|
||||
status: "queued",
|
||||
status: 'queued',
|
||||
startedAt: Date.now(),
|
||||
completedAt: undefined,
|
||||
});
|
||||
})
|
||||
|
||||
// Convert config object to flags string
|
||||
const flags: string[] = [];
|
||||
const flags: string[] = []
|
||||
|
||||
// Handle Modules (Inverted Logic: Default Excluded)
|
||||
for (const module of modulesData.modules) {
|
||||
// If config[id] is NOT false (explicitly included), we exclude it.
|
||||
if (profile.config[module.id] !== false) {
|
||||
flags.push(`-D${module.id}=1`);
|
||||
flags.push(`-D${module.id}=1`)
|
||||
}
|
||||
}
|
||||
|
||||
const flagsString = flags.join(" ");
|
||||
const flagsString = flags.join(' ')
|
||||
|
||||
// Compute build hash for retry using flags
|
||||
const buildHash = await computeBuildHash(
|
||||
profile.version,
|
||||
build.target,
|
||||
flagsString,
|
||||
);
|
||||
flagsString
|
||||
)
|
||||
|
||||
console.log(`Computed retry hash: ${buildHash} (Flags: ${flagsString})`);
|
||||
console.log(`Computed retry hash: ${buildHash} (Flags: ${flagsString})`)
|
||||
|
||||
await ctx.scheduler.runAfter(0, api.actions.dispatchGithubBuild, {
|
||||
buildId: args.buildId,
|
||||
@@ -245,82 +243,82 @@ export const retryBuild = mutation({
|
||||
flags: flagsString,
|
||||
version: profile.version,
|
||||
buildHash: buildHash,
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Internal mutation to log errors from actions
|
||||
export const logBuildError = internalMutation({
|
||||
args: {
|
||||
buildId: v.id("builds"),
|
||||
buildId: v.id('builds'),
|
||||
error: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.buildId, {
|
||||
status: "failure",
|
||||
status: 'failure',
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
// Internal mutation to update build status
|
||||
export const updateBuildStatus = internalMutation({
|
||||
args: {
|
||||
buildId: v.id("builds"),
|
||||
buildId: v.id('builds'),
|
||||
status: v.string(), // Accepts any status string value
|
||||
artifactUrl: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const build = await ctx.db.get(args.buildId);
|
||||
if (!build) return;
|
||||
const build = await ctx.db.get(args.buildId)
|
||||
if (!build) return
|
||||
|
||||
const updateData: BuildUpdateData = {
|
||||
status: args.status,
|
||||
};
|
||||
}
|
||||
|
||||
// Only set completedAt for final statuses
|
||||
if (args.status === "success" || args.status === "failure") {
|
||||
updateData.completedAt = Date.now();
|
||||
if (args.status === 'success' || args.status === 'failure') {
|
||||
updateData.completedAt = Date.now()
|
||||
}
|
||||
|
||||
if (args.artifactUrl) {
|
||||
updateData.artifactUrl = args.artifactUrl;
|
||||
updateData.artifactUrl = args.artifactUrl
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.buildId, updateData);
|
||||
await ctx.db.patch(args.buildId, updateData)
|
||||
|
||||
// If build succeeded, store in cache with R2 URL
|
||||
if (args.status === "success" && build.buildHash && build.target) {
|
||||
if (args.status === 'success' && build.buildHash && build.target) {
|
||||
// Get version from profile
|
||||
const profile = await ctx.db.get(build.profileId);
|
||||
const profile = await ctx.db.get(build.profileId)
|
||||
if (profile) {
|
||||
// Construct R2 URL from hash
|
||||
const artifactUrl = getR2ArtifactUrl(build.buildHash);
|
||||
const artifactUrl = getR2ArtifactUrl(build.buildHash)
|
||||
|
||||
// Update build with R2 URL if not already set
|
||||
if (!args.artifactUrl) {
|
||||
await ctx.db.patch(args.buildId, { artifactUrl });
|
||||
await ctx.db.patch(args.buildId, { artifactUrl })
|
||||
}
|
||||
|
||||
// Check if cache entry already exists
|
||||
const existing = await ctx.db
|
||||
.query("buildCache")
|
||||
.withIndex("by_hash_target", (q) =>
|
||||
q.eq("buildHash", build.buildHash).eq("target", build.target),
|
||||
.query('buildCache')
|
||||
.withIndex('by_hash_target', (q) =>
|
||||
q.eq('buildHash', build.buildHash).eq('target', build.target)
|
||||
)
|
||||
.first();
|
||||
.first()
|
||||
|
||||
if (!existing) {
|
||||
// Store in cache
|
||||
await ctx.db.insert("buildCache", {
|
||||
await ctx.db.insert('buildCache', {
|
||||
buildHash: build.buildHash,
|
||||
target: build.target,
|
||||
artifactUrl: artifactUrl,
|
||||
version: profile.version,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
+18
-18
@@ -1,46 +1,46 @@
|
||||
import { httpRouter } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { httpAction } from "./_generated/server";
|
||||
import { auth } from "./auth";
|
||||
import { httpRouter } from 'convex/server'
|
||||
import { internal } from './_generated/api'
|
||||
import { httpAction } from './_generated/server'
|
||||
import { auth } from './auth'
|
||||
|
||||
const http = httpRouter();
|
||||
const http = httpRouter()
|
||||
|
||||
auth.addHttpRoutes(http);
|
||||
auth.addHttpRoutes(http)
|
||||
|
||||
http.route({
|
||||
path: "/github-webhook",
|
||||
method: "POST",
|
||||
path: '/github-webhook',
|
||||
method: 'POST',
|
||||
handler: httpAction(async (ctx, request) => {
|
||||
const payload = await request.json();
|
||||
const payload = await request.json()
|
||||
|
||||
// Verify signature (TODO: Add HMAC verification)
|
||||
|
||||
// Validate build_id is present
|
||||
if (!payload.build_id || !payload.status) {
|
||||
return new Response("Missing build_id or status", { status: 400 });
|
||||
return new Response('Missing build_id or status', { status: 400 })
|
||||
}
|
||||
|
||||
// Verify build exists
|
||||
const build = await ctx.runMutation(internal.builds.getInternal, {
|
||||
buildId: payload.build_id,
|
||||
});
|
||||
})
|
||||
|
||||
if (!build) {
|
||||
return new Response("Build not found", { status: 404 });
|
||||
return new Response('Build not found', { status: 404 })
|
||||
}
|
||||
|
||||
// Handle status updates (intermediate statuses) and completion (final statuses)
|
||||
if (payload.action === "status_update" || payload.action === "completed") {
|
||||
if (payload.action === 'status_update' || payload.action === 'completed') {
|
||||
await ctx.runMutation(internal.builds.updateBuildStatus, {
|
||||
buildId: payload.build_id,
|
||||
status: payload.status,
|
||||
});
|
||||
})
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
return new Response(null, { status: 200 })
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
return new Response(null, { status: 200 })
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
export default http;
|
||||
export default http
|
||||
|
||||
+157
-157
@@ -1,159 +1,159 @@
|
||||
{
|
||||
"modules": [
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_ADMIN",
|
||||
"name": "Admin",
|
||||
"description": "Remote device configuration and management. Allows changing settings, reading device info, and rebooting nodes over the mesh network."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_ATAK",
|
||||
"name": "ATAK Plugin",
|
||||
"description": "Integration with ATAK (Android Team Awareness Kit) for tactical situational awareness. Enables military/emergency response coordination."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_AUDIO",
|
||||
"name": "Audio",
|
||||
"description": "Audio codec support for voice communication over the mesh."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_BLUETOOTH",
|
||||
"name": "Bluetooth",
|
||||
"description": "Bluetooth connectivity for pairing with phones and apps. Required for mobile app communication on most devices."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_CANNEDMESSAGES",
|
||||
"name": "Canned Messages",
|
||||
"description": "Pre-defined quick messages that can be sent with button presses. Useful for devices with limited input (no keyboard). Includes on-screen keyboard for some devices."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_DETECTIONSENSOR",
|
||||
"name": "Detection Sensor",
|
||||
"description": "Motion/presence detection sensor integration. Broadcasts detection events when sensors trigger (PIR, door switches, etc.)."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR",
|
||||
"name": "Environmental Sensor",
|
||||
"description": "Environmental monitoring sensors including temperature, humidity, pressure, air quality, and light sensors. Broadcasts telemetry data to the mesh."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION",
|
||||
"name": "External Notification",
|
||||
"description": "Drive external LEDs, buzzers, and speakers for notifications. Plays RTTTL ringtones and can control GPIO outputs when messages arrive."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_GPS",
|
||||
"name": "GPS",
|
||||
"description": "GPS receiver support for position tracking and sharing. Disabling prevents position broadcasts but the device still relays position packets for other nodes."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY",
|
||||
"name": "Health Telemetry",
|
||||
"description": "Heart rate and health monitoring sensors (like MAX30102 pulse oximeter). Broadcasts health metrics to the mesh."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_I2C",
|
||||
"name": "I2C",
|
||||
"description": "I2C bus support for external sensors and peripherals. Required for most sensor modules and OLED displays."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_INPUTBROKER",
|
||||
"name": "Input Broker",
|
||||
"description": "Input device handling (buttons, encoders, touchscreens). Routes button presses to appropriate modules like Canned Messages."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_MQTT",
|
||||
"name": "MQTT",
|
||||
"description": "MQTT client for cloud integration. Publishes mesh messages to MQTT brokers and subscribes to receive cloud messages. Enables IoT integration and remote monitoring."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_NEIGHBORINFO",
|
||||
"name": "Neighbor Info",
|
||||
"description": "Broadcasts information about directly-reachable neighbor nodes including signal strength (SNR). Helps build mesh topology maps and track network health."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_PAXCOUNTER",
|
||||
"name": "Pax Counter",
|
||||
"description": "Counts nearby WiFi and Bluetooth devices for crowd density estimation. Useful for people-counting in public spaces."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_PKI",
|
||||
"name": "PKI",
|
||||
"description": "Public Key Infrastructure for enhanced security and key verification between nodes."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWERMON",
|
||||
"name": "Power Monitor",
|
||||
"description": "Battery and power monitoring hardware support (INA260, INA219, etc.). Tracks voltage, current, and power consumption."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWER_FSM",
|
||||
"name": "Power FSM",
|
||||
"description": "Power management finite state machine. Handles sleep modes, power state transitions, and battery optimization."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWER_TELEMETRY",
|
||||
"name": "Power Telemetry",
|
||||
"description": "Broadcasts battery voltage, current, and power consumption data to the mesh. Different from Power Monitor which is the hardware interface."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWERSTRESS",
|
||||
"name": "Power Stress",
|
||||
"description": "Power consumption testing tool. Stresses the device to measure battery life under various transmission patterns. For development/testing only."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_RANGETEST",
|
||||
"name": "Range Test",
|
||||
"description": "Mesh range and signal quality testing. Sends periodic packets and logs signal strength (RSSI), SNR, and packet loss statistics to a file."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_REMOTEHARDWARE",
|
||||
"name": "Remote Hardware",
|
||||
"description": "Remote GPIO control over the mesh. Read/write digital pins, read ADC values, and control hardware on remote nodes."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_SCREEN",
|
||||
"name": "Screen",
|
||||
"description": "OLED/E-Ink display support. Shows messages, node info, and status on screen. Disabling saves power but removes visual feedback."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_SERIAL",
|
||||
"name": "Serial",
|
||||
"description": "Serial port communication for sensors and external devices. Can relay serial data over the mesh and supports NMEA GPS bridging."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_STOREFORWARD",
|
||||
"name": "Store & Forward",
|
||||
"description": "Message store-and-forward server for offline nodes. Router devices can cache messages and replay them when distant nodes reconnect. Requires PSRAM."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_TEXTMESSAGE",
|
||||
"name": "Text Messaging",
|
||||
"description": "Send and receive text messages between nodes. Displays messages on OLED screens and forwards to connected apps. **Important:** Disabling prevents sending/receiving but the node still relays messages for others."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_TRACEROUTE",
|
||||
"name": "Traceroute",
|
||||
"description": "Network path tracing tool. Shows the route packets take through the mesh, including all intermediate hops and hop limits."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_TZ",
|
||||
"name": "Timezone",
|
||||
"description": "Timezone database support for local time display. Allows devices to show correct local time based on GPS position."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_WAYPOINT",
|
||||
"name": "Waypoint",
|
||||
"description": "Share and display waypoints (points of interest) on the mesh. Shows waypoints on screen and in apps for navigation and location marking."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_WEBSERVER",
|
||||
"name": "Web Server",
|
||||
"description": "Built-in web interface for device configuration. Automatically excluded if WiFi is disabled."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_WIFI",
|
||||
"name": "WiFi",
|
||||
"description": "WiFi connectivity for network access, web server, and MQTT. Disabling saves power but removes WiFi features including the web interface."
|
||||
}
|
||||
]
|
||||
"modules": [
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_ADMIN",
|
||||
"name": "Admin",
|
||||
"description": "Remote device configuration and management. Allows changing settings, reading device info, and rebooting nodes over the mesh network."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_ATAK",
|
||||
"name": "ATAK Plugin",
|
||||
"description": "Integration with ATAK (Android Team Awareness Kit) for tactical situational awareness. Enables military/emergency response coordination."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_AUDIO",
|
||||
"name": "Audio",
|
||||
"description": "Audio codec support for voice communication over the mesh."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_BLUETOOTH",
|
||||
"name": "Bluetooth",
|
||||
"description": "Bluetooth connectivity for pairing with phones and apps. Required for mobile app communication on most devices."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_CANNEDMESSAGES",
|
||||
"name": "Canned Messages",
|
||||
"description": "Pre-defined quick messages that can be sent with button presses. Useful for devices with limited input (no keyboard). Includes on-screen keyboard for some devices."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_DETECTIONSENSOR",
|
||||
"name": "Detection Sensor",
|
||||
"description": "Motion/presence detection sensor integration. Broadcasts detection events when sensors trigger (PIR, door switches, etc.)."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR",
|
||||
"name": "Environmental Sensor",
|
||||
"description": "Environmental monitoring sensors including temperature, humidity, pressure, air quality, and light sensors. Broadcasts telemetry data to the mesh."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_EXTERNALNOTIFICATION",
|
||||
"name": "External Notification",
|
||||
"description": "Drive external LEDs, buzzers, and speakers for notifications. Plays RTTTL ringtones and can control GPIO outputs when messages arrive."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_GPS",
|
||||
"name": "GPS",
|
||||
"description": "GPS receiver support for position tracking and sharing. Disabling prevents position broadcasts but the device still relays position packets for other nodes."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_HEALTH_TELEMETRY",
|
||||
"name": "Health Telemetry",
|
||||
"description": "Heart rate and health monitoring sensors (like MAX30102 pulse oximeter). Broadcasts health metrics to the mesh."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_I2C",
|
||||
"name": "I2C",
|
||||
"description": "I2C bus support for external sensors and peripherals. Required for most sensor modules and OLED displays."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_INPUTBROKER",
|
||||
"name": "Input Broker",
|
||||
"description": "Input device handling (buttons, encoders, touchscreens). Routes button presses to appropriate modules like Canned Messages."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_MQTT",
|
||||
"name": "MQTT",
|
||||
"description": "MQTT client for cloud integration. Publishes mesh messages to MQTT brokers and subscribes to receive cloud messages. Enables IoT integration and remote monitoring."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_NEIGHBORINFO",
|
||||
"name": "Neighbor Info",
|
||||
"description": "Broadcasts information about directly-reachable neighbor nodes including signal strength (SNR). Helps build mesh topology maps and track network health."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_PAXCOUNTER",
|
||||
"name": "Pax Counter",
|
||||
"description": "Counts nearby WiFi and Bluetooth devices for crowd density estimation. Useful for people-counting in public spaces."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_PKI",
|
||||
"name": "PKI",
|
||||
"description": "Public Key Infrastructure for enhanced security and key verification between nodes."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWERMON",
|
||||
"name": "Power Monitor",
|
||||
"description": "Battery and power monitoring hardware support (INA260, INA219, etc.). Tracks voltage, current, and power consumption."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWER_FSM",
|
||||
"name": "Power FSM",
|
||||
"description": "Power management finite state machine. Handles sleep modes, power state transitions, and battery optimization."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWER_TELEMETRY",
|
||||
"name": "Power Telemetry",
|
||||
"description": "Broadcasts battery voltage, current, and power consumption data to the mesh. Different from Power Monitor which is the hardware interface."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_POWERSTRESS",
|
||||
"name": "Power Stress",
|
||||
"description": "Power consumption testing tool. Stresses the device to measure battery life under various transmission patterns. For development/testing only."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_RANGETEST",
|
||||
"name": "Range Test",
|
||||
"description": "Mesh range and signal quality testing. Sends periodic packets and logs signal strength (RSSI), SNR, and packet loss statistics to a file."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_REMOTEHARDWARE",
|
||||
"name": "Remote Hardware",
|
||||
"description": "Remote GPIO control over the mesh. Read/write digital pins, read ADC values, and control hardware on remote nodes."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_SCREEN",
|
||||
"name": "Screen",
|
||||
"description": "OLED/E-Ink display support. Shows messages, node info, and status on screen. Disabling saves power but removes visual feedback."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_SERIAL",
|
||||
"name": "Serial",
|
||||
"description": "Serial port communication for sensors and external devices. Can relay serial data over the mesh and supports NMEA GPS bridging."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_STOREFORWARD",
|
||||
"name": "Store & Forward",
|
||||
"description": "Message store-and-forward server for offline nodes. Router devices can cache messages and replay them when distant nodes reconnect. Requires PSRAM."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_TEXTMESSAGE",
|
||||
"name": "Text Messaging",
|
||||
"description": "Send and receive text messages between nodes. Displays messages on OLED screens and forwards to connected apps. **Important:** Disabling prevents sending/receiving but the node still relays messages for others."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_TRACEROUTE",
|
||||
"name": "Traceroute",
|
||||
"description": "Network path tracing tool. Shows the route packets take through the mesh, including all intermediate hops and hop limits."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_TZ",
|
||||
"name": "Timezone",
|
||||
"description": "Timezone database support for local time display. Allows devices to show correct local time based on GPS position."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_WAYPOINT",
|
||||
"name": "Waypoint",
|
||||
"description": "Share and display waypoints (points of interest) on the mesh. Shows waypoints on screen and in apps for navigation and location marking."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_WEBSERVER",
|
||||
"name": "Web Server",
|
||||
"description": "Built-in web interface for device configuration. Automatically excluded if WiFi is disabled."
|
||||
},
|
||||
{
|
||||
"id": "MESHTASTIC_EXCLUDE_WIFI",
|
||||
"name": "WiFi",
|
||||
"description": "WiFi connectivity for network access, web server, and MQTT. Disabling saves power but removes WiFi features including the web interface."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+66
-66
@@ -1,80 +1,80 @@
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { getAuthUserId } from '@convex-dev/auth/server'
|
||||
import { v } from 'convex/values'
|
||||
import { mutation, query } from './_generated/server'
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) return [];
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) return []
|
||||
|
||||
return await ctx.db
|
||||
.query("profiles")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
return await ctx.db
|
||||
.query('profiles')
|
||||
.withIndex('by_user', (q) => q.eq('userId', userId))
|
||||
.collect()
|
||||
},
|
||||
})
|
||||
|
||||
export const create = mutation({
|
||||
args: {
|
||||
name: v.string(),
|
||||
targets: v.array(v.string()),
|
||||
config: v.any(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
args: {
|
||||
name: v.string(),
|
||||
targets: v.array(v.string()),
|
||||
config: v.any(),
|
||||
version: v.string(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
return await ctx.db.insert("profiles", {
|
||||
userId,
|
||||
name: args.name,
|
||||
targets: args.targets,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
return await ctx.db.insert('profiles', {
|
||||
userId,
|
||||
name: args.name,
|
||||
targets: args.targets,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const update = mutation({
|
||||
args: {
|
||||
id: v.id("profiles"),
|
||||
name: v.string(),
|
||||
targets: v.array(v.string()),
|
||||
config: v.any(),
|
||||
version: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
args: {
|
||||
id: v.id('profiles'),
|
||||
name: v.string(),
|
||||
targets: v.array(v.string()),
|
||||
config: v.any(),
|
||||
version: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const profile = await ctx.db.get(args.id);
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const profile = await ctx.db.get(args.id)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
await ctx.db.patch(args.id, {
|
||||
name: args.name,
|
||||
targets: args.targets,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
await ctx.db.patch(args.id, {
|
||||
name: args.name,
|
||||
targets: args.targets,
|
||||
config: args.config,
|
||||
version: args.version,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const remove = mutation({
|
||||
args: { id: v.id("profiles") },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
args: { id: v.id('profiles') },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx)
|
||||
if (!userId) throw new Error('Unauthorized')
|
||||
|
||||
const profile = await ctx.db.get(args.id);
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const profile = await ctx.db.get(args.id)
|
||||
if (!profile || profile.userId !== userId) {
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
|
||||
await ctx.db.delete(args.id);
|
||||
},
|
||||
});
|
||||
await ctx.db.delete(args.id)
|
||||
},
|
||||
})
|
||||
|
||||
+9
-9
@@ -1,19 +1,19 @@
|
||||
import { authTables } from "@convex-dev/auth/server";
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { authTables } from '@convex-dev/auth/server'
|
||||
import { defineSchema, defineTable } from 'convex/server'
|
||||
import { v } from 'convex/values'
|
||||
|
||||
export default defineSchema({
|
||||
...authTables,
|
||||
profiles: defineTable({
|
||||
userId: v.id("users"),
|
||||
userId: v.id('users'),
|
||||
name: v.string(),
|
||||
targets: v.array(v.string()), // e.g. ["tbeam", "rak4631"]
|
||||
config: v.any(), // JSON object for flags
|
||||
version: v.string(),
|
||||
updatedAt: v.number(),
|
||||
}).index("by_user", ["userId"]),
|
||||
}).index('by_user', ['userId']),
|
||||
builds: defineTable({
|
||||
profileId: v.id("profiles"),
|
||||
profileId: v.id('profiles'),
|
||||
target: v.string(),
|
||||
githubRunId: v.number(),
|
||||
status: v.string(), // Accepts arbitrary status strings (e.g., "queued", "checking_out", "building", "uploading", "success", "failure")
|
||||
@@ -21,7 +21,7 @@ export default defineSchema({
|
||||
startedAt: v.number(),
|
||||
completedAt: v.optional(v.number()),
|
||||
buildHash: v.string(),
|
||||
}).index("by_profile", ["profileId"]),
|
||||
}).index('by_profile', ['profileId']),
|
||||
|
||||
buildCache: defineTable({
|
||||
buildHash: v.string(),
|
||||
@@ -29,5 +29,5 @@ export default defineSchema({
|
||||
artifactUrl: v.string(),
|
||||
version: v.string(),
|
||||
createdAt: v.number(),
|
||||
}).index("by_hash_target", ["buildHash", "target"]),
|
||||
});
|
||||
}).index('by_hash_target', ['buildHash', 'target']),
|
||||
})
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"dev": "bun run generate:versions && vite",
|
||||
"build": "bun run generate:versions && tsc && vite build",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome lint --fix",
|
||||
"lint:fix": "biome lint --fix && biome format --write",
|
||||
"preview": "vite preview",
|
||||
"deploy": "npx convex deploy --cmd 'bun run build' && wrangler deploy"
|
||||
},
|
||||
|
||||
+31
-31
@@ -1,37 +1,37 @@
|
||||
import { Authenticated, AuthLoading, Unauthenticated } from "convex/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import BuildDetail from "./pages/BuildDetail";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import { Authenticated, AuthLoading, Unauthenticated } from 'convex/react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BuildDetail from './pages/BuildDetail'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import LandingPage from './pages/LandingPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthLoading>
|
||||
<div className="flex items-center justify-center min-h-screen bg-slate-950">
|
||||
<Loader2 className="w-10 h-10 text-cyan-500 animate-spin" />
|
||||
</div>
|
||||
</AuthLoading>
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthLoading>
|
||||
<div className="flex items-center justify-center min-h-screen bg-slate-950">
|
||||
<Loader2 className="w-10 h-10 text-cyan-500 animate-spin" />
|
||||
</div>
|
||||
</AuthLoading>
|
||||
|
||||
<Unauthenticated>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Unauthenticated>
|
||||
<Unauthenticated>
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Unauthenticated>
|
||||
|
||||
<Authenticated>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/builds/:buildId" element={<BuildDetail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Authenticated>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
);
|
||||
<Authenticated>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/builds/:buildId" element={<BuildDetail />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Authenticated>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
||||
@@ -1,82 +1,75 @@
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { humanizeStatus, timeAgo } from "@/lib/utils";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { CheckCircle, Loader2, RotateCw, Trash2, XCircle } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { humanizeStatus, timeAgo } from '@/lib/utils'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Id } from '../../convex/_generated/dataModel'
|
||||
|
||||
interface BuildsPanelProps {
|
||||
profileId: Id<"profiles">;
|
||||
profileId: Id<'profiles'>
|
||||
}
|
||||
|
||||
export default function BuildsPanel({ profileId }: BuildsPanelProps) {
|
||||
const builds = useQuery(api.builds.listByProfile, { profileId });
|
||||
const deleteBuild = useMutation(api.builds.deleteBuild);
|
||||
const retryBuild = useMutation(api.builds.retryBuild);
|
||||
const builds = useQuery(api.builds.listByProfile, { profileId })
|
||||
const deleteBuild = useMutation(api.builds.deleteBuild)
|
||||
const retryBuild = useMutation(api.builds.retryBuild)
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === "success") {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
if (status === 'success') {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />
|
||||
}
|
||||
if (status === "failure") {
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
if (status === 'failure') {
|
||||
return <XCircle className="w-4 h-4 text-red-500" />
|
||||
}
|
||||
// All other statuses show as in progress
|
||||
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />;
|
||||
};
|
||||
return <Loader2 className="w-4 h-4 text-blue-500 animate-spin" />
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === "success") {
|
||||
return "text-green-400";
|
||||
if (status === 'success') {
|
||||
return 'text-green-400'
|
||||
}
|
||||
if (status === "failure") {
|
||||
return "text-red-400";
|
||||
if (status === 'failure') {
|
||||
return 'text-red-400'
|
||||
}
|
||||
// All other statuses show as in progress
|
||||
return "text-blue-400";
|
||||
};
|
||||
return 'text-blue-400'
|
||||
}
|
||||
|
||||
const handleDelete = async (buildId: Id<"builds">) => {
|
||||
const handleDelete = async (buildId: Id<'builds'>) => {
|
||||
try {
|
||||
await deleteBuild({ buildId });
|
||||
toast.success("Build deleted", {
|
||||
description: "Build record has been removed.",
|
||||
});
|
||||
await deleteBuild({ buildId })
|
||||
toast.success('Build deleted', {
|
||||
description: 'Build record has been removed.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error("Delete failed", {
|
||||
toast.error('Delete failed', {
|
||||
description: String(error),
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRetry = async (buildId: Id<"builds">) => {
|
||||
const handleRetry = async (buildId: Id<'builds'>) => {
|
||||
try {
|
||||
await retryBuild({ buildId });
|
||||
toast.success("Build retrying", {
|
||||
description: "Build has been queued again.",
|
||||
});
|
||||
await retryBuild({ buildId })
|
||||
toast.success('Build retrying', {
|
||||
description: 'Build has been queued again.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error("Retry failed", {
|
||||
toast.error('Retry failed', {
|
||||
description: String(error),
|
||||
});
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!builds || builds.length === 0) {
|
||||
return (
|
||||
<div className="text-slate-500 text-sm py-4">
|
||||
No builds yet. Click "Build" to start.
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -108,14 +101,14 @@ export default function BuildsPanel({ profileId }: BuildsPanelProps) {
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{build.status === "failure" && (
|
||||
{build.status === 'failure' && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-slate-400 hover:text-white"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRetry(build._id);
|
||||
e.preventDefault()
|
||||
handleRetry(build._id)
|
||||
}}
|
||||
title="Retry Build"
|
||||
>
|
||||
@@ -127,8 +120,8 @@ export default function BuildsPanel({ profileId }: BuildsPanelProps) {
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete(build._id);
|
||||
e.preventDefault()
|
||||
handleDelete(build._id)
|
||||
}}
|
||||
title="Delete Build"
|
||||
>
|
||||
@@ -139,5 +132,5 @@ export default function BuildsPanel({ profileId }: BuildsPanelProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
interface ModuleCardProps {
|
||||
name: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
name: string
|
||||
description: string
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function ModuleCard({
|
||||
name,
|
||||
description,
|
||||
selected,
|
||||
onClick,
|
||||
name,
|
||||
description,
|
||||
selected,
|
||||
onClick,
|
||||
}: ModuleCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full text-left p-4 rounded-lg border-2 transition-all
|
||||
${
|
||||
selected
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: "border-slate-700 bg-slate-900/50 hover:border-slate-600"
|
||||
}
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-500/10'
|
||||
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<div
|
||||
className={`
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<div
|
||||
className={`
|
||||
w-5 h-5 rounded border-2 flex items-center justify-center
|
||||
${selected ? "border-blue-500 bg-blue-500" : "border-slate-500"}
|
||||
${selected ? 'border-blue-500 bg-blue-500' : 'border-slate-500'}
|
||||
`}
|
||||
>
|
||||
{selected && (
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<title>Checkmark</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm mb-1">{name}</h4>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
>
|
||||
{selected && (
|
||||
<svg
|
||||
className="w-3 h-3 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<title>Checkmark</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm mb-1">{name}</h4>
|
||||
<p className="text-xs text-slate-400 leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
+216
-216
@@ -1,241 +1,241 @@
|
||||
import { useMutation } from "convex/react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc } from "../../convex/_generated/dataModel";
|
||||
import modulesData from "../../convex/modules.json";
|
||||
import { TARGETS } from "../constants/targets";
|
||||
import { VERSIONS } from "../constants/versions";
|
||||
import { ModuleCard } from "./ModuleCard";
|
||||
import { useMutation } from 'convex/react'
|
||||
import * as React from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Doc } from '../../convex/_generated/dataModel'
|
||||
import modulesData from '../../convex/modules.json'
|
||||
import { TARGETS } from '../constants/targets'
|
||||
import { VERSIONS } from '../constants/versions'
|
||||
import { ModuleCard } from './ModuleCard'
|
||||
|
||||
interface ProfileFormValues {
|
||||
name: string;
|
||||
targets: string[];
|
||||
config: Record<string, boolean>;
|
||||
version: string;
|
||||
name: string
|
||||
targets: string[]
|
||||
config: Record<string, boolean>
|
||||
version: string
|
||||
}
|
||||
|
||||
interface ProfileEditorProps {
|
||||
initialData?: Doc<"profiles">;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
initialData?: Doc<'profiles'>
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function ProfileEditor({
|
||||
initialData,
|
||||
onSave,
|
||||
onCancel,
|
||||
initialData,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: ProfileEditorProps) {
|
||||
const createProfile = useMutation(api.profiles.create);
|
||||
const updateProfile = useMutation(api.profiles.update);
|
||||
const createProfile = useMutation(api.profiles.create)
|
||||
const updateProfile = useMutation(api.profiles.update)
|
||||
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: initialData || {
|
||||
name: "",
|
||||
targets: [],
|
||||
config: {},
|
||||
version: VERSIONS[0],
|
||||
},
|
||||
});
|
||||
const { register, handleSubmit, setValue, watch } = useForm({
|
||||
defaultValues: initialData || {
|
||||
name: '',
|
||||
targets: [],
|
||||
config: {},
|
||||
version: VERSIONS[0],
|
||||
},
|
||||
})
|
||||
|
||||
const targets = watch("targets");
|
||||
const targets = watch('targets')
|
||||
|
||||
// Group targets by category
|
||||
const groupedTargets = React.useMemo(() => {
|
||||
return Object.entries(TARGETS).reduce(
|
||||
(acc, [id, meta]) => {
|
||||
const category = meta.category || "Other";
|
||||
if (!acc[category]) acc[category] = [];
|
||||
acc[category].push({ id, ...meta });
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ((typeof TARGETS)[string] & { id: string })[]>,
|
||||
);
|
||||
}, []);
|
||||
// Group targets by category
|
||||
const groupedTargets = React.useMemo(() => {
|
||||
return Object.entries(TARGETS).reduce(
|
||||
(acc, [id, meta]) => {
|
||||
const category = meta.category || 'Other'
|
||||
if (!acc[category]) acc[category] = []
|
||||
acc[category].push({ id, ...meta })
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ((typeof TARGETS)[string] & { id: string })[]>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const categories = React.useMemo(
|
||||
() => Object.keys(groupedTargets).sort(),
|
||||
[groupedTargets],
|
||||
);
|
||||
const categories = React.useMemo(
|
||||
() => Object.keys(groupedTargets).sort(),
|
||||
[groupedTargets]
|
||||
)
|
||||
|
||||
const [activeCategory, setActiveCategory] = React.useState<string>(
|
||||
categories[0] || "Heltec",
|
||||
);
|
||||
const [activeCategory, setActiveCategory] = React.useState<string>(
|
||||
categories[0] || 'Heltec'
|
||||
)
|
||||
|
||||
// Update active category when categories load if not set
|
||||
React.useEffect(() => {
|
||||
if (!activeCategory && categories.length > 0) {
|
||||
setActiveCategory(categories[0]);
|
||||
}
|
||||
}, [categories, activeCategory]);
|
||||
// Update active category when categories load if not set
|
||||
React.useEffect(() => {
|
||||
if (!activeCategory && categories.length > 0) {
|
||||
setActiveCategory(categories[0])
|
||||
}
|
||||
}, [categories, activeCategory])
|
||||
|
||||
const toggleTarget = (target: string) => {
|
||||
const current = targets || [];
|
||||
if (current.includes(target)) {
|
||||
setValue(
|
||||
"targets",
|
||||
current.filter((t: string) => t !== target),
|
||||
);
|
||||
} else {
|
||||
setValue("targets", [...current, target]);
|
||||
}
|
||||
};
|
||||
const toggleTarget = (target: string) => {
|
||||
const current = targets || []
|
||||
if (current.includes(target)) {
|
||||
setValue(
|
||||
'targets',
|
||||
current.filter((t: string) => t !== target)
|
||||
)
|
||||
} else {
|
||||
setValue('targets', [...current, target])
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async (data: ProfileFormValues) => {
|
||||
if (initialData?._id) {
|
||||
await updateProfile({
|
||||
id: initialData._id,
|
||||
name: data.name,
|
||||
targets: data.targets,
|
||||
config: data.config,
|
||||
version: data.version,
|
||||
});
|
||||
} else {
|
||||
await createProfile(data);
|
||||
}
|
||||
onSave();
|
||||
};
|
||||
const onSubmit = async (data: ProfileFormValues) => {
|
||||
if (initialData?._id) {
|
||||
await updateProfile({
|
||||
id: initialData._id,
|
||||
name: data.name,
|
||||
targets: data.targets,
|
||||
config: data.config,
|
||||
version: data.version,
|
||||
})
|
||||
} else {
|
||||
await createProfile(data)
|
||||
}
|
||||
onSave()
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Profile Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register("name")}
|
||||
className="bg-slate-950 border-slate-800"
|
||||
placeholder="e.g. Solar Repeater"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="version" className="block text-sm font-medium mb-2">
|
||||
Firmware Version
|
||||
</label>
|
||||
<select
|
||||
id="version"
|
||||
{...register("version")}
|
||||
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
|
||||
>
|
||||
{VERSIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6 bg-slate-900 p-6 rounded-lg border border-slate-800"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Profile Name
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
{...register('name')}
|
||||
className="bg-slate-950 border-slate-800"
|
||||
placeholder="e.g. Solar Repeater"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="version" className="block text-sm font-medium mb-2">
|
||||
Firmware Version
|
||||
</label>
|
||||
<select
|
||||
id="version"
|
||||
{...register('version')}
|
||||
className="w-full h-10 px-3 rounded-md border border-slate-800 bg-slate-950 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-950"
|
||||
>
|
||||
{VERSIONS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="block text-sm font-medium mb-2">Targets</div>
|
||||
<div className="space-y-4">
|
||||
{/* Category Pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => {
|
||||
const count = groupedTargets[category].filter((t) =>
|
||||
targets?.includes(t.id),
|
||||
).length;
|
||||
const isActive = activeCategory === category;
|
||||
<div>
|
||||
<div className="block text-sm font-medium mb-2">Targets</div>
|
||||
<div className="space-y-4">
|
||||
{/* Category Pills */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => {
|
||||
const count = groupedTargets[category].filter((t) =>
|
||||
targets?.includes(t.id)
|
||||
).length
|
||||
const isActive = activeCategory === category
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-slate-800 text-slate-300 hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{count > 0 && (
|
||||
<span className="ml-2 bg-white/20 px-1.5 py-0.5 rounded-full text-xs">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<button
|
||||
key={category}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
{count > 0 && (
|
||||
<span className="ml-2 bg-white/20 px-1.5 py-0.5 rounded-full text-xs">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Active Category Targets */}
|
||||
<div className="bg-slate-950/50 p-4 rounded-lg border border-slate-800/50">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{groupedTargets[activeCategory]?.map((item) => (
|
||||
<div key={item.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={targets?.includes(item.id)}
|
||||
onCheckedChange={() => toggleTarget(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{item.name}
|
||||
{item.architecture && (
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
({item.architecture})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Active Category Targets */}
|
||||
<div className="bg-slate-950/50 p-4 rounded-lg border border-slate-800/50">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{groupedTargets[activeCategory]?.map((item) => (
|
||||
<div key={item.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={targets?.includes(item.id)}
|
||||
onCheckedChange={() => toggleTarget(item.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{item.name}
|
||||
{item.architecture && (
|
||||
<span className="ml-2 text-xs text-slate-500">
|
||||
({item.architecture})
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium">Modules</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Select the modules to include in your build.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{modulesData.modules.map((module) => {
|
||||
// Inverted logic:
|
||||
// config[id] === false -> Explicitly Included
|
||||
// config[id] === true or undefined -> Excluded
|
||||
const configValue = watch(`config.${module.id}`);
|
||||
const isIncluded = configValue === false;
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg font-medium">Modules</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
Select the modules to include in your build.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{modulesData.modules.map((module) => {
|
||||
// Inverted logic:
|
||||
// config[id] === false -> Explicitly Included
|
||||
// config[id] === true or undefined -> Excluded
|
||||
const configValue = watch(`config.${module.id}`)
|
||||
const isIncluded = configValue === false
|
||||
|
||||
return (
|
||||
<ModuleCard
|
||||
key={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
selected={isIncluded}
|
||||
onClick={() => {
|
||||
// Toggle:
|
||||
// If currently included (true), we want to exclude (set config to true)
|
||||
// If currently excluded (false), we want to include (set config to false)
|
||||
setValue(`config.${module.id}`, !!isIncluded);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<ModuleCard
|
||||
key={module.id}
|
||||
name={module.name}
|
||||
description={module.description}
|
||||
selected={isIncluded}
|
||||
onClick={() => {
|
||||
// Toggle:
|
||||
// If currently included (true), we want to exclude (set config to true)
|
||||
// If currently excluded (false), we want to include (set config to false)
|
||||
setValue(`config.${module.id}`, !!isIncluded)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Profile</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
<div className="flex justify-end gap-4 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Profile</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants };
|
||||
export { Button, buttonVariants }
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
||||
import { Check } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn('grid place-content-center text-current')}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox };
|
||||
export { Checkbox }
|
||||
|
||||
+19
-19
@@ -1,22 +1,22 @@
|
||||
import * as React from "react";
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input };
|
||||
export { Input }
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Toaster as Sonner } from 'sonner'
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { theme = 'system' } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={theme as ToasterProps['theme']}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||
description: 'group-[.toast]:text-muted-foreground',
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
|
||||
+16
-16
@@ -1,24 +1,24 @@
|
||||
import hardwareList from "../../vendor/web-flasher/public/data/hardware-list.json";
|
||||
import hardwareList from '../../vendor/web-flasher/public/data/hardware-list.json'
|
||||
|
||||
export interface TargetMetadata {
|
||||
name: string;
|
||||
category: string;
|
||||
architecture?: string;
|
||||
name: string
|
||||
category: string
|
||||
architecture?: string
|
||||
}
|
||||
|
||||
export const TARGETS: Record<string, TargetMetadata> = {};
|
||||
export const TARGETS: Record<string, TargetMetadata> = {}
|
||||
|
||||
// Sort by display name
|
||||
const sortedHardware = [...hardwareList].sort((a, b) =>
|
||||
(a.displayName || "").localeCompare(b.displayName || "")
|
||||
);
|
||||
const sortedHardware = [...hardwareList].sort((a, b) =>
|
||||
(a.displayName || '').localeCompare(b.displayName || '')
|
||||
)
|
||||
|
||||
sortedHardware.forEach((hw) => {
|
||||
if (hw.platformioTarget) {
|
||||
TARGETS[hw.platformioTarget] = {
|
||||
name: hw.displayName || hw.platformioTarget,
|
||||
category: hw.tags?.[0] || "Other",
|
||||
architecture: hw.architecture,
|
||||
};
|
||||
}
|
||||
});
|
||||
if (hw.platformioTarget) {
|
||||
TARGETS[hw.platformioTarget] = {
|
||||
name: hw.displayName || hw.platformioTarget,
|
||||
category: hw.tags?.[0] || 'Other',
|
||||
architecture: hw.architecture,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+239
-239
@@ -1,242 +1,242 @@
|
||||
// This file is auto-generated by scripts/generate-versions.js
|
||||
export const VERSIONS = [
|
||||
"v2.7.15.567b8ea",
|
||||
"v2.7.14.e959000",
|
||||
"v2.7.13.597fa0b",
|
||||
"v2.7.12.45f15b8",
|
||||
"v2.7.11.ee68575",
|
||||
"v2.7.10.94d4bdf",
|
||||
"v2.7.9.70724be",
|
||||
"v2.7.8.a0c0388",
|
||||
"v2.7.7.5ae4ff9",
|
||||
"v2.7.6.834c3c5",
|
||||
"v2.7.5.ddd1499",
|
||||
"v2.7.4.c1f4f79",
|
||||
"v2.7.3.cf574c7",
|
||||
"v2.7.2.f6d3782",
|
||||
"v2.7.1.f35ca81",
|
||||
"v2.7.0.705515a",
|
||||
"v2.7.0.195b7cc",
|
||||
"v2.6.13.0561f2c",
|
||||
"v2.6.12.9861e82",
|
||||
"v2.6.11.60ec05e",
|
||||
"v2.6.10.9ce4455",
|
||||
"v2.6.9.f223b8a",
|
||||
"v2.6.8.ef9d0d7",
|
||||
"v2.6.7.2d6181f",
|
||||
"v2.6.6.54c1423",
|
||||
"v2.6.5.fc3d9f2",
|
||||
"v2.6.4.b89355f",
|
||||
"v2.6.3.d28af68",
|
||||
"v2.6.3.640e731",
|
||||
"v2.6.2.31c0e8f",
|
||||
"v2.6.1.7c3edde",
|
||||
"v2.6.0.f7afa9a",
|
||||
"v2.5.23.bf958ed",
|
||||
"v2.5.22.d1fa27d",
|
||||
"v2.5.21.447533a",
|
||||
"v2.5.20.4c97351",
|
||||
"v2.5.19.f9876cf",
|
||||
"v2.5.19.d5cd6f8",
|
||||
"v2.5.18.89ebafc",
|
||||
"v2.5.17.b4b2fd6",
|
||||
"v2.5.16.f81d3b0",
|
||||
"v2.5.15.79da236",
|
||||
"v2.5.14.f2ee0df",
|
||||
"v2.5.13.295278b",
|
||||
"v2.5.13.1a06f88",
|
||||
"v2.5.12.aa184e6",
|
||||
"v2.5.11.8e2a3e5",
|
||||
"v2.5.10.0fc5c9b",
|
||||
"v2.5.9.936260f",
|
||||
"v2.5.8.6485f03",
|
||||
"v2.5.7.f77c87d",
|
||||
"v2.5.6.d55c08d",
|
||||
"v2.5.5.e182ae7",
|
||||
"v2.5.4.8d288d5",
|
||||
"v2.5.3.a70d5ee",
|
||||
"v2.5.2.771cb52",
|
||||
"v2.5.1.c13b44b",
|
||||
"v2.5.0.e470619",
|
||||
"v2.5.0.d6dac17",
|
||||
"v2.5.0.ab7de7f",
|
||||
"v2.5.0.33eb073",
|
||||
"v2.5.0.9e55e6b",
|
||||
"v2.5.0.9ac0e26",
|
||||
"v2.4.3.efc27f2",
|
||||
"v2.4.3.91d6612",
|
||||
"v2.4.2.5b45303",
|
||||
"v2.4.1.394e0e1",
|
||||
"v2.4.0.46d7b82",
|
||||
"v2.3.15.deb7c27",
|
||||
"v2.3.14.64531fa",
|
||||
"v2.3.13.83f5ba0",
|
||||
"v2.3.12.24458a7",
|
||||
"v2.3.11.2740a56",
|
||||
"v2.3.10.d19607b",
|
||||
"v2.3.9.f06c56a",
|
||||
"v2.3.8.d490a33",
|
||||
"v2.3.7.30fbcab",
|
||||
"v2.3.6.7a3570a",
|
||||
"v2.3.5.2f9b68e",
|
||||
"v2.3.4.ea61808",
|
||||
"v2.3.3.8187fa7",
|
||||
"v2.3.2.63df972",
|
||||
"v2.3.1.4fa7f5a",
|
||||
"v2.3.0.5f47ca1",
|
||||
"v2.2.24.e6a2c06",
|
||||
"v2.2.23.5672e68",
|
||||
"v2.2.22.404d0dd",
|
||||
"v2.2.21.7f7c5cb",
|
||||
"v2.2.20.af5ac32",
|
||||
"v2.2.19.8f6a283",
|
||||
"v2.2.18.e9bde80",
|
||||
"v2.2.17.dbac2b1",
|
||||
"v2.2.16.1c6acfd",
|
||||
"v2.2.15.31c4693",
|
||||
"v2.2.14.57542ce",
|
||||
"v2.2.13.f570204",
|
||||
"v2.2.12.092e6f2",
|
||||
"v2.2.11.10265aa",
|
||||
"v2.2.10.7cebd79",
|
||||
"v2.2.9.47301a5",
|
||||
"v2.2.8.61f6fb2",
|
||||
"v2.2.7.e8970ad",
|
||||
"v2.2.6.b53cb38",
|
||||
"v2.2.5.8255128",
|
||||
"v2.2.4.3bcab0e",
|
||||
"v2.2.3.282cc0b",
|
||||
"v2.2.2.f35c7be",
|
||||
"v2.2.1.fb5f2e4",
|
||||
"v2.2.0.9f6584b",
|
||||
"v2.1.23.04bbdc6",
|
||||
"v2.1.22.191a69d",
|
||||
"v2.1.21.97d7a89",
|
||||
"v2.1.20.470363d",
|
||||
"v2.1.19.eb7025f",
|
||||
"v2.1.18.de53280",
|
||||
"v2.1.17.7ca2e81",
|
||||
"v2.1.16.a2c5b92",
|
||||
"v2.1.15.cd78723",
|
||||
"v2.1.14.99a31c1",
|
||||
"v2.1.13.7475c86",
|
||||
"v2.1.12.7711b03",
|
||||
"v2.1.11.5ec624d",
|
||||
"v2.1.10.7ef12c7",
|
||||
"v2.1.9.d43ddc9",
|
||||
"v2.1.8.ee971e3",
|
||||
"v2.1.7.242f880",
|
||||
"v2.1.6.5679a82",
|
||||
"v2.1.5.23272da",
|
||||
"v2.1.4.958d2cf",
|
||||
"v2.1.3.8c68d88",
|
||||
"v2.1.2.6d20215",
|
||||
"v2.1.1.dc2ca9c",
|
||||
"v2.1.0.331a1af",
|
||||
"v2.0.23.7bb281d",
|
||||
"v2.0.22.fbfd0f1",
|
||||
"v2.0.21.83e6cea",
|
||||
"v2.0.20.7100416",
|
||||
"v2.0.19.3209aea",
|
||||
"v2.0.18.1a7991c",
|
||||
"v2.0.17.5d1c06b",
|
||||
"v2.0.16.2242b68",
|
||||
"v2.0.15.aafbde0",
|
||||
"v2.0.14.2baaad8",
|
||||
"v2.0.13.7e27729",
|
||||
"v2.0.12.2400dd4",
|
||||
"v2.0.11.8914d1a",
|
||||
"v2.0.10.e09b12c",
|
||||
"v2.0.9.6ea0963",
|
||||
"v2.0.8.090e166",
|
||||
"v2.0.7.91ff7b9",
|
||||
"v2.0.6.97fd5cf",
|
||||
"v2.0.5.65e8209",
|
||||
"v2.0.4.5417671",
|
||||
"v2.0.3.09fe616",
|
||||
"v2.0.2.8146e84",
|
||||
"v2.0.1.ad05b91",
|
||||
"v2.0.0.18ab874",
|
||||
"v1.3.48.82bcd39",
|
||||
"v1.3.47.05147c0",
|
||||
"v1.3.46.d4ea956",
|
||||
"v1.3.45.b0d0552",
|
||||
"v1.3.44.4fa8d02",
|
||||
"v1.3.43.aae9d2f",
|
||||
"v1.3.42.9bd9252",
|
||||
"v1.3.41.80ddb81",
|
||||
"v1.3.40.e87ecc2",
|
||||
"v1.3.39.ddc3727",
|
||||
"v1.3.38.1253abd",
|
||||
"v1.3.37.97712a9",
|
||||
"v1.3.36.dd720f2",
|
||||
"v1.3.36.64f852e",
|
||||
"v1.3.36.7e03019",
|
||||
"v1.3.35.3251cd5",
|
||||
"v1.3.34.401b5d9",
|
||||
"v1.3.33.ab0095c",
|
||||
"v1.3.32.7e6c22f",
|
||||
"v1.3.31.0084643",
|
||||
"v1.3.30.9fe2ddb",
|
||||
"v1.3.29.7afc149",
|
||||
"v1.3.28.41f9541",
|
||||
"v1.3.27.c88ba58",
|
||||
"v1.3.26.0010231",
|
||||
"v1.3.25.85f46d3",
|
||||
"v1.3.24.dff6915",
|
||||
"v1.3.23.5462d84",
|
||||
"v1.3.22.c725a6b",
|
||||
"v1.3.21.cf00ac5",
|
||||
"v1.3.20.9a5ff93",
|
||||
"v1.3.19.3c6a2f7",
|
||||
"v1.3.17.c9822de",
|
||||
"v1.3.16.97899ae",
|
||||
"v1.3.15.432d067",
|
||||
"v1.3.13.71a43a9",
|
||||
"v1.3.12.6306c53",
|
||||
"v1.3.11.0411401",
|
||||
"v1.3.10.cc2a84a",
|
||||
"v1.3.10.4df0e91",
|
||||
"v1.3.9.92185e7",
|
||||
"v1.3.8.90df7c2",
|
||||
"v1.3.7.bb22b6e",
|
||||
"v1.3.6.f511bab",
|
||||
"v1.3.5.e5b19fd",
|
||||
"v1.3.4.2b20bf3",
|
||||
"v1.3.3.2fe124e",
|
||||
"v1.2.testing1",
|
||||
"v1.2.65.0adc5ce",
|
||||
"v1.2.64.fc48fcd",
|
||||
"v1.2.63.9879494",
|
||||
"v1.2.62.3ddd74e",
|
||||
"v1.2.61.d551c17",
|
||||
"v1.2.60.ab959de",
|
||||
"v1.2.59.d81c1c0",
|
||||
"v1.2.58.6af1822",
|
||||
"v1.2.57.f7c6955",
|
||||
"v1.2.56.596a73c",
|
||||
"v1.2.55.9db7c62",
|
||||
"v1.2.54.288f2be",
|
||||
"v1.2.53.19c1f9f",
|
||||
"v1.2.52.b63802c",
|
||||
"v1.2.51.f9ff06b",
|
||||
"v1.2.50.41dcfdd",
|
||||
"v1.2.49.5354c49",
|
||||
"v1.2.48.371335e",
|
||||
"v1.2.47",
|
||||
"v1.2.46.dce2fe4",
|
||||
"v1.2.46.9d21e58",
|
||||
"v1.2.45.b674054",
|
||||
"v1.2.44.f2c9c55",
|
||||
"v1.2.43.a405d81",
|
||||
"v1.2.42.2759c8d",
|
||||
"v1.2.41.32f3682",
|
||||
"v1.2.39.06892c4",
|
||||
"v1.2.38.cf4e508",
|
||||
"v1.2.38.451b085",
|
||||
"v1.2.36",
|
||||
"v1.2.30.80e4bc6",
|
||||
"v1.2.29.6c95659"
|
||||
] as const;
|
||||
'v2.7.15.567b8ea',
|
||||
'v2.7.14.e959000',
|
||||
'v2.7.13.597fa0b',
|
||||
'v2.7.12.45f15b8',
|
||||
'v2.7.11.ee68575',
|
||||
'v2.7.10.94d4bdf',
|
||||
'v2.7.9.70724be',
|
||||
'v2.7.8.a0c0388',
|
||||
'v2.7.7.5ae4ff9',
|
||||
'v2.7.6.834c3c5',
|
||||
'v2.7.5.ddd1499',
|
||||
'v2.7.4.c1f4f79',
|
||||
'v2.7.3.cf574c7',
|
||||
'v2.7.2.f6d3782',
|
||||
'v2.7.1.f35ca81',
|
||||
'v2.7.0.705515a',
|
||||
'v2.7.0.195b7cc',
|
||||
'v2.6.13.0561f2c',
|
||||
'v2.6.12.9861e82',
|
||||
'v2.6.11.60ec05e',
|
||||
'v2.6.10.9ce4455',
|
||||
'v2.6.9.f223b8a',
|
||||
'v2.6.8.ef9d0d7',
|
||||
'v2.6.7.2d6181f',
|
||||
'v2.6.6.54c1423',
|
||||
'v2.6.5.fc3d9f2',
|
||||
'v2.6.4.b89355f',
|
||||
'v2.6.3.d28af68',
|
||||
'v2.6.3.640e731',
|
||||
'v2.6.2.31c0e8f',
|
||||
'v2.6.1.7c3edde',
|
||||
'v2.6.0.f7afa9a',
|
||||
'v2.5.23.bf958ed',
|
||||
'v2.5.22.d1fa27d',
|
||||
'v2.5.21.447533a',
|
||||
'v2.5.20.4c97351',
|
||||
'v2.5.19.f9876cf',
|
||||
'v2.5.19.d5cd6f8',
|
||||
'v2.5.18.89ebafc',
|
||||
'v2.5.17.b4b2fd6',
|
||||
'v2.5.16.f81d3b0',
|
||||
'v2.5.15.79da236',
|
||||
'v2.5.14.f2ee0df',
|
||||
'v2.5.13.295278b',
|
||||
'v2.5.13.1a06f88',
|
||||
'v2.5.12.aa184e6',
|
||||
'v2.5.11.8e2a3e5',
|
||||
'v2.5.10.0fc5c9b',
|
||||
'v2.5.9.936260f',
|
||||
'v2.5.8.6485f03',
|
||||
'v2.5.7.f77c87d',
|
||||
'v2.5.6.d55c08d',
|
||||
'v2.5.5.e182ae7',
|
||||
'v2.5.4.8d288d5',
|
||||
'v2.5.3.a70d5ee',
|
||||
'v2.5.2.771cb52',
|
||||
'v2.5.1.c13b44b',
|
||||
'v2.5.0.e470619',
|
||||
'v2.5.0.d6dac17',
|
||||
'v2.5.0.ab7de7f',
|
||||
'v2.5.0.33eb073',
|
||||
'v2.5.0.9e55e6b',
|
||||
'v2.5.0.9ac0e26',
|
||||
'v2.4.3.efc27f2',
|
||||
'v2.4.3.91d6612',
|
||||
'v2.4.2.5b45303',
|
||||
'v2.4.1.394e0e1',
|
||||
'v2.4.0.46d7b82',
|
||||
'v2.3.15.deb7c27',
|
||||
'v2.3.14.64531fa',
|
||||
'v2.3.13.83f5ba0',
|
||||
'v2.3.12.24458a7',
|
||||
'v2.3.11.2740a56',
|
||||
'v2.3.10.d19607b',
|
||||
'v2.3.9.f06c56a',
|
||||
'v2.3.8.d490a33',
|
||||
'v2.3.7.30fbcab',
|
||||
'v2.3.6.7a3570a',
|
||||
'v2.3.5.2f9b68e',
|
||||
'v2.3.4.ea61808',
|
||||
'v2.3.3.8187fa7',
|
||||
'v2.3.2.63df972',
|
||||
'v2.3.1.4fa7f5a',
|
||||
'v2.3.0.5f47ca1',
|
||||
'v2.2.24.e6a2c06',
|
||||
'v2.2.23.5672e68',
|
||||
'v2.2.22.404d0dd',
|
||||
'v2.2.21.7f7c5cb',
|
||||
'v2.2.20.af5ac32',
|
||||
'v2.2.19.8f6a283',
|
||||
'v2.2.18.e9bde80',
|
||||
'v2.2.17.dbac2b1',
|
||||
'v2.2.16.1c6acfd',
|
||||
'v2.2.15.31c4693',
|
||||
'v2.2.14.57542ce',
|
||||
'v2.2.13.f570204',
|
||||
'v2.2.12.092e6f2',
|
||||
'v2.2.11.10265aa',
|
||||
'v2.2.10.7cebd79',
|
||||
'v2.2.9.47301a5',
|
||||
'v2.2.8.61f6fb2',
|
||||
'v2.2.7.e8970ad',
|
||||
'v2.2.6.b53cb38',
|
||||
'v2.2.5.8255128',
|
||||
'v2.2.4.3bcab0e',
|
||||
'v2.2.3.282cc0b',
|
||||
'v2.2.2.f35c7be',
|
||||
'v2.2.1.fb5f2e4',
|
||||
'v2.2.0.9f6584b',
|
||||
'v2.1.23.04bbdc6',
|
||||
'v2.1.22.191a69d',
|
||||
'v2.1.21.97d7a89',
|
||||
'v2.1.20.470363d',
|
||||
'v2.1.19.eb7025f',
|
||||
'v2.1.18.de53280',
|
||||
'v2.1.17.7ca2e81',
|
||||
'v2.1.16.a2c5b92',
|
||||
'v2.1.15.cd78723',
|
||||
'v2.1.14.99a31c1',
|
||||
'v2.1.13.7475c86',
|
||||
'v2.1.12.7711b03',
|
||||
'v2.1.11.5ec624d',
|
||||
'v2.1.10.7ef12c7',
|
||||
'v2.1.9.d43ddc9',
|
||||
'v2.1.8.ee971e3',
|
||||
'v2.1.7.242f880',
|
||||
'v2.1.6.5679a82',
|
||||
'v2.1.5.23272da',
|
||||
'v2.1.4.958d2cf',
|
||||
'v2.1.3.8c68d88',
|
||||
'v2.1.2.6d20215',
|
||||
'v2.1.1.dc2ca9c',
|
||||
'v2.1.0.331a1af',
|
||||
'v2.0.23.7bb281d',
|
||||
'v2.0.22.fbfd0f1',
|
||||
'v2.0.21.83e6cea',
|
||||
'v2.0.20.7100416',
|
||||
'v2.0.19.3209aea',
|
||||
'v2.0.18.1a7991c',
|
||||
'v2.0.17.5d1c06b',
|
||||
'v2.0.16.2242b68',
|
||||
'v2.0.15.aafbde0',
|
||||
'v2.0.14.2baaad8',
|
||||
'v2.0.13.7e27729',
|
||||
'v2.0.12.2400dd4',
|
||||
'v2.0.11.8914d1a',
|
||||
'v2.0.10.e09b12c',
|
||||
'v2.0.9.6ea0963',
|
||||
'v2.0.8.090e166',
|
||||
'v2.0.7.91ff7b9',
|
||||
'v2.0.6.97fd5cf',
|
||||
'v2.0.5.65e8209',
|
||||
'v2.0.4.5417671',
|
||||
'v2.0.3.09fe616',
|
||||
'v2.0.2.8146e84',
|
||||
'v2.0.1.ad05b91',
|
||||
'v2.0.0.18ab874',
|
||||
'v1.3.48.82bcd39',
|
||||
'v1.3.47.05147c0',
|
||||
'v1.3.46.d4ea956',
|
||||
'v1.3.45.b0d0552',
|
||||
'v1.3.44.4fa8d02',
|
||||
'v1.3.43.aae9d2f',
|
||||
'v1.3.42.9bd9252',
|
||||
'v1.3.41.80ddb81',
|
||||
'v1.3.40.e87ecc2',
|
||||
'v1.3.39.ddc3727',
|
||||
'v1.3.38.1253abd',
|
||||
'v1.3.37.97712a9',
|
||||
'v1.3.36.dd720f2',
|
||||
'v1.3.36.64f852e',
|
||||
'v1.3.36.7e03019',
|
||||
'v1.3.35.3251cd5',
|
||||
'v1.3.34.401b5d9',
|
||||
'v1.3.33.ab0095c',
|
||||
'v1.3.32.7e6c22f',
|
||||
'v1.3.31.0084643',
|
||||
'v1.3.30.9fe2ddb',
|
||||
'v1.3.29.7afc149',
|
||||
'v1.3.28.41f9541',
|
||||
'v1.3.27.c88ba58',
|
||||
'v1.3.26.0010231',
|
||||
'v1.3.25.85f46d3',
|
||||
'v1.3.24.dff6915',
|
||||
'v1.3.23.5462d84',
|
||||
'v1.3.22.c725a6b',
|
||||
'v1.3.21.cf00ac5',
|
||||
'v1.3.20.9a5ff93',
|
||||
'v1.3.19.3c6a2f7',
|
||||
'v1.3.17.c9822de',
|
||||
'v1.3.16.97899ae',
|
||||
'v1.3.15.432d067',
|
||||
'v1.3.13.71a43a9',
|
||||
'v1.3.12.6306c53',
|
||||
'v1.3.11.0411401',
|
||||
'v1.3.10.cc2a84a',
|
||||
'v1.3.10.4df0e91',
|
||||
'v1.3.9.92185e7',
|
||||
'v1.3.8.90df7c2',
|
||||
'v1.3.7.bb22b6e',
|
||||
'v1.3.6.f511bab',
|
||||
'v1.3.5.e5b19fd',
|
||||
'v1.3.4.2b20bf3',
|
||||
'v1.3.3.2fe124e',
|
||||
'v1.2.testing1',
|
||||
'v1.2.65.0adc5ce',
|
||||
'v1.2.64.fc48fcd',
|
||||
'v1.2.63.9879494',
|
||||
'v1.2.62.3ddd74e',
|
||||
'v1.2.61.d551c17',
|
||||
'v1.2.60.ab959de',
|
||||
'v1.2.59.d81c1c0',
|
||||
'v1.2.58.6af1822',
|
||||
'v1.2.57.f7c6955',
|
||||
'v1.2.56.596a73c',
|
||||
'v1.2.55.9db7c62',
|
||||
'v1.2.54.288f2be',
|
||||
'v1.2.53.19c1f9f',
|
||||
'v1.2.52.b63802c',
|
||||
'v1.2.51.f9ff06b',
|
||||
'v1.2.50.41dcfdd',
|
||||
'v1.2.49.5354c49',
|
||||
'v1.2.48.371335e',
|
||||
'v1.2.47',
|
||||
'v1.2.46.dce2fe4',
|
||||
'v1.2.46.9d21e58',
|
||||
'v1.2.45.b674054',
|
||||
'v1.2.44.f2c9c55',
|
||||
'v1.2.43.a405d81',
|
||||
'v1.2.42.2759c8d',
|
||||
'v1.2.41.32f3682',
|
||||
'v1.2.39.06892c4',
|
||||
'v1.2.38.cf4e508',
|
||||
'v1.2.38.451b085',
|
||||
'v1.2.36',
|
||||
'v1.2.30.80e4bc6',
|
||||
'v1.2.29.6c95659',
|
||||
] as const
|
||||
|
||||
export type FirmwareVersion = typeof VERSIONS[number];
|
||||
export type FirmwareVersion = (typeof VERSIONS)[number]
|
||||
|
||||
+25
-25
@@ -1,32 +1,32 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-primary: oklch(0.488 0.243 264.376);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.269 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.269 0 0);
|
||||
--color-muted-foreground: oklch(0.708 0 0);
|
||||
--color-accent: oklch(0.488 0.243 264.376);
|
||||
--color-accent-foreground: oklch(0.985 0 0);
|
||||
--color-destructive: oklch(0.704 0.191 22.216);
|
||||
--color-border: oklch(1 0 0 / 10%);
|
||||
--color-input: oklch(1 0 0 / 15%);
|
||||
--color-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
--radius: 0.5rem;
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
|
||||
--color-background: oklch(0.145 0 0);
|
||||
--color-foreground: oklch(0.985 0 0);
|
||||
--color-primary: oklch(0.488 0.243 264.376);
|
||||
--color-primary-foreground: oklch(0.985 0 0);
|
||||
--color-secondary: oklch(0.269 0 0);
|
||||
--color-secondary-foreground: oklch(0.985 0 0);
|
||||
--color-muted: oklch(0.269 0 0);
|
||||
--color-muted-foreground: oklch(0.708 0 0);
|
||||
--color-accent: oklch(0.488 0.243 264.376);
|
||||
--color-accent-foreground: oklch(0.985 0 0);
|
||||
--color-destructive: oklch(0.704 0.191 22.216);
|
||||
--color-border: oklch(1 0 0 / 10%);
|
||||
--color-input: oklch(1 0 0 / 15%);
|
||||
--color-ring: oklch(0.488 0.243 264.376);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
+23
-23
@@ -1,46 +1,46 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function timeAgo(date: number | string | Date): string {
|
||||
const now = new Date();
|
||||
const past = new Date(date);
|
||||
const msPerMinute = 60 * 1000;
|
||||
const msPerHour = msPerMinute * 60;
|
||||
const msPerDay = msPerHour * 24;
|
||||
const msPerMonth = msPerDay * 30;
|
||||
const msPerYear = msPerDay * 365;
|
||||
const now = new Date()
|
||||
const past = new Date(date)
|
||||
const msPerMinute = 60 * 1000
|
||||
const msPerHour = msPerMinute * 60
|
||||
const msPerDay = msPerHour * 24
|
||||
const msPerMonth = msPerDay * 30
|
||||
const msPerYear = msPerDay * 365
|
||||
|
||||
const elapsed = now.getTime() - past.getTime();
|
||||
const elapsed = now.getTime() - past.getTime()
|
||||
|
||||
if (elapsed < msPerMinute) {
|
||||
return `${Math.round(elapsed / 1000)}s ago`;
|
||||
return `${Math.round(elapsed / 1000)}s ago`
|
||||
} else if (elapsed < msPerHour) {
|
||||
return `${Math.round(elapsed / msPerMinute)}m ago`;
|
||||
return `${Math.round(elapsed / msPerMinute)}m ago`
|
||||
} else if (elapsed < msPerDay) {
|
||||
return `${Math.round(elapsed / msPerHour)}h ago`;
|
||||
return `${Math.round(elapsed / msPerHour)}h ago`
|
||||
} else if (elapsed < msPerMonth) {
|
||||
return `${Math.round(elapsed / msPerDay)}d ago`;
|
||||
return `${Math.round(elapsed / msPerDay)}d ago`
|
||||
} else if (elapsed < msPerYear) {
|
||||
return `${Math.round(elapsed / msPerMonth)}mo ago`;
|
||||
return `${Math.round(elapsed / msPerMonth)}mo ago`
|
||||
} else {
|
||||
return `${Math.round(elapsed / msPerYear)}y ago`;
|
||||
return `${Math.round(elapsed / msPerYear)}y ago`
|
||||
}
|
||||
}
|
||||
|
||||
export function humanizeStatus(status: string): string {
|
||||
// Handle special statuses
|
||||
if (status === "success") return "Success";
|
||||
if (status === "failure") return "Failure";
|
||||
if (status === "queued") return "Queued";
|
||||
if (status === "in_progress") return "In Progress";
|
||||
if (status === 'success') return 'Success'
|
||||
if (status === 'failure') return 'Failure'
|
||||
if (status === 'queued') return 'Queued'
|
||||
if (status === 'in_progress') return 'In Progress'
|
||||
|
||||
// Convert snake_case/underscore_separated to Title Case
|
||||
return status
|
||||
.split("_")
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
+15
-15
@@ -1,21 +1,21 @@
|
||||
import { ConvexAuthProvider } from "@convex-dev/auth/react";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { ConvexAuthProvider } from '@convex-dev/auth/react'
|
||||
import { ConvexReactClient } from 'convex/react'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string)
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
throw new Error('Root element not found')
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<ConvexAuthProvider client={convex}>
|
||||
<App />
|
||||
</ConvexAuthProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
<StrictMode>
|
||||
<ConvexAuthProvider client={convex}>
|
||||
<App />
|
||||
</ConvexAuthProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
+26
-26
@@ -1,29 +1,29 @@
|
||||
import { useQuery } from "convex/react";
|
||||
import { useQuery } from 'convex/react'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle,
|
||||
Download,
|
||||
Loader2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { humanizeStatus } from "@/lib/utils";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Id } from "../../convex/_generated/dataModel";
|
||||
} from 'lucide-react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { humanizeStatus } from '@/lib/utils'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Id } from '../../convex/_generated/dataModel'
|
||||
|
||||
export default function BuildDetail() {
|
||||
const { buildId } = useParams<{ buildId: string }>();
|
||||
const { buildId } = useParams<{ buildId: string }>()
|
||||
const build = useQuery(api.builds.get, {
|
||||
buildId: buildId as Id<"builds">,
|
||||
});
|
||||
buildId: buildId as Id<'builds'>,
|
||||
})
|
||||
|
||||
if (build === undefined) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-slate-950 text-white">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-500" />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (build === null) {
|
||||
@@ -34,30 +34,30 @@ export default function BuildDetail() {
|
||||
<Button variant="outline">Return to Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === "success") {
|
||||
return "text-green-400";
|
||||
if (status === 'success') {
|
||||
return 'text-green-400'
|
||||
}
|
||||
if (status === "failure") {
|
||||
return "text-red-400";
|
||||
if (status === 'failure') {
|
||||
return 'text-red-400'
|
||||
}
|
||||
// All other statuses show as in progress
|
||||
return "text-blue-400";
|
||||
};
|
||||
return 'text-blue-400'
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === "success") {
|
||||
return <CheckCircle className="w-6 h-6 text-green-500" />;
|
||||
if (status === 'success') {
|
||||
return <CheckCircle className="w-6 h-6 text-green-500" />
|
||||
}
|
||||
if (status === "failure") {
|
||||
return <XCircle className="w-6 h-6 text-red-500" />;
|
||||
if (status === 'failure') {
|
||||
return <XCircle className="w-6 h-6 text-red-500" />
|
||||
}
|
||||
// All other statuses show as in progress
|
||||
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />;
|
||||
};
|
||||
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
@@ -87,7 +87,7 @@ export default function BuildDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{build.status === "success" && build.artifactUrl && (
|
||||
{build.status === 'success' && build.artifactUrl && (
|
||||
<a
|
||||
href={build.artifactUrl}
|
||||
target="_blank"
|
||||
@@ -102,5 +102,5 @@ export default function BuildDetail() {
|
||||
</header>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
+132
-132
@@ -1,143 +1,143 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import BuildsPanel from "@/components/BuildsPanel";
|
||||
import ProfileEditor from "@/components/ProfileEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { useAuthActions } from '@convex-dev/auth/react'
|
||||
import { useMutation, useQuery } from 'convex/react'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import BuildsPanel from '@/components/BuildsPanel'
|
||||
import ProfileEditor from '@/components/ProfileEditor'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { api } from '../../convex/_generated/api'
|
||||
import type { Doc, Id } from '../../convex/_generated/dataModel'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { signOut } = useAuthActions();
|
||||
const profiles = useQuery(api.profiles.list);
|
||||
const triggerBuild = useMutation(api.builds.triggerBuild);
|
||||
const removeProfile = useMutation(api.profiles.remove);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingProfile, setEditingProfile] = useState<Doc<"profiles"> | null>(
|
||||
null,
|
||||
);
|
||||
const { signOut } = useAuthActions()
|
||||
const profiles = useQuery(api.profiles.list)
|
||||
const triggerBuild = useMutation(api.builds.triggerBuild)
|
||||
const removeProfile = useMutation(api.profiles.remove)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editingProfile, setEditingProfile] = useState<Doc<'profiles'> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const handleEdit = (profile: Doc<"profiles">) => {
|
||||
setEditingProfile(profile);
|
||||
setIsEditing(true);
|
||||
};
|
||||
const handleEdit = (profile: Doc<'profiles'>) => {
|
||||
setEditingProfile(profile)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingProfile(null);
|
||||
setIsEditing(true);
|
||||
};
|
||||
const handleCreate = () => {
|
||||
setEditingProfile(null)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
const handleBuild = async (profileId: Id<"profiles">) => {
|
||||
try {
|
||||
await triggerBuild({ profileId });
|
||||
toast.success("Build started", {
|
||||
description: "Check the build status below.",
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Build failed", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleBuild = async (profileId: Id<'profiles'>) => {
|
||||
try {
|
||||
await triggerBuild({ profileId })
|
||||
toast.success('Build started', {
|
||||
description: 'Check the build status below.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Build failed', {
|
||||
description: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (
|
||||
profileId: Id<"profiles">,
|
||||
profileName: string,
|
||||
) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete "${profileName}"? This action cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const handleDelete = async (
|
||||
profileId: Id<'profiles'>,
|
||||
profileName: string
|
||||
) => {
|
||||
if (
|
||||
!confirm(
|
||||
`Are you sure you want to delete "${profileName}"? This action cannot be undone.`
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await removeProfile({ id: profileId });
|
||||
toast.success("Profile deleted", {
|
||||
description: `"${profileName}" has been deleted successfully.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Delete failed", {
|
||||
description: String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
await removeProfile({ id: profileId })
|
||||
toast.success('Profile deleted', {
|
||||
description: `"${profileName}" has been deleted successfully.`,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error('Delete failed', {
|
||||
description: String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold">My Fleet</h1>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> New Profile
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => signOut()}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white p-8">
|
||||
<header className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold">My Fleet</h1>
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-cyan-600 hover:bg-cyan-700"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" /> New Profile
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => signOut()}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{isEditing ? (
|
||||
<ProfileEditor
|
||||
initialData={editingProfile}
|
||||
onSave={() => setIsEditing(false)}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{profiles?.map((profile) => (
|
||||
<div
|
||||
key={profile._id}
|
||||
className="border border-slate-800 rounded-lg p-6 bg-slate-900/50"
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
|
||||
<p className="text-slate-400 text-sm mb-1">
|
||||
Version:{" "}
|
||||
<span className="text-slate-200">{profile.version}</span>
|
||||
</p>
|
||||
<p className="text-slate-400 text-sm mb-4">
|
||||
Targets: {profile.targets.join(", ")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleEdit(profile)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handleBuild(profile._id)}>
|
||||
Build
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(profile._id, profile.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<main>
|
||||
{isEditing ? (
|
||||
<ProfileEditor
|
||||
initialData={editingProfile}
|
||||
onSave={() => setIsEditing(false)}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{profiles?.map((profile) => (
|
||||
<div
|
||||
key={profile._id}
|
||||
className="border border-slate-800 rounded-lg p-6 bg-slate-900/50"
|
||||
>
|
||||
<h3 className="text-xl font-semibold mb-2">{profile.name}</h3>
|
||||
<p className="text-slate-400 text-sm mb-1">
|
||||
Version:{' '}
|
||||
<span className="text-slate-200">{profile.version}</span>
|
||||
</p>
|
||||
<p className="text-slate-400 text-sm mb-4">
|
||||
Targets: {profile.targets.join(', ')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleEdit(profile)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handleBuild(profile._id)}>
|
||||
Build
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(profile._id, profile.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-800">
|
||||
<BuildsPanel profileId={profile._id} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{profiles?.length === 0 && (
|
||||
<div className="col-span-3 text-center text-slate-500 py-12">
|
||||
No profiles found. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
<div className="mt-4 pt-4 border-t border-slate-800">
|
||||
<BuildsPanel profileId={profile._id} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{profiles?.length === 0 && (
|
||||
<div className="col-span-3 text-center text-slate-500 py-12">
|
||||
No profiles found. Create one to get started.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+20
-20
@@ -1,24 +1,24 @@
|
||||
import { useAuthActions } from "@convex-dev/auth/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAuthActions } from '@convex-dev/auth/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export default function LandingPage() {
|
||||
const { signIn } = useAuthActions();
|
||||
const { signIn } = useAuthActions()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white">
|
||||
<h1 className="text-5xl font-bold mb-8 bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
|
||||
Firmware Configurator
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 mb-12 max-w-md text-center">
|
||||
Manage your Meshtastic fleet. Create custom profiles, build firmware in
|
||||
the cloud, and flash directly from your browser.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => signIn("google")}
|
||||
className="bg-white text-slate-900 hover:bg-slate-200 text-lg px-8 py-6"
|
||||
>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-950 text-white">
|
||||
<h1 className="text-5xl font-bold mb-8 bg-gradient-to-r from-cyan-400 to-blue-600 bg-clip-text text-transparent">
|
||||
Firmware Configurator
|
||||
</h1>
|
||||
<p className="text-xl text-slate-400 mb-12 max-w-md text-center">
|
||||
Manage your Meshtastic fleet. Create custom profiles, build firmware in
|
||||
the cloud, and flash directly from your browser.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => signIn('google')}
|
||||
className="bg-white text-slate-900 hover:bg-slate-200 text-lg px-8 py-6"
|
||||
>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "react-web-flasher",
|
||||
"compatibility_date": "2024-09-23",
|
||||
"assets": {
|
||||
"directory": "./dist"
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true
|
||||
}
|
||||
"name": "react-web-flasher",
|
||||
"compatibility_date": "2024-09-23",
|
||||
"assets": {
|
||||
"directory": "./dist"
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user